diff --git a/.github/workflows/docker_develop.yml b/.github/workflows/docker_develop.yml index 97066cc0..cbffb703 100644 --- a/.github/workflows/docker_develop.yml +++ b/.github/workflows/docker_develop.yml @@ -1,9 +1,11 @@ name: Develop - build image and push # This workflow builds, tests, and pushes a Docker image to GitHub Container Registry. +# This workflow builds, tests, and pushes a Docker image to GitHub Container Registry. # It is triggered on pushes and pull requests to the "develop" branch, and can also be triggered manually. # The image is tagged with "develop" and a version string based on the workflow run number. # For pull requests, the Docker image is uploaded as an artifact instead of being pushed to the registry. # After publishing, old Docker images with the "develop" version suffix are cleaned up to save space. +# After publishing, old Docker images with the "develop" version suffix are cleaned up to save space. # The workflow uses the GITHUB_TOKEN secret for authentication with GitHub Container Registry. # Jobs: @@ -12,6 +14,12 @@ name: Develop - build image and push # - publish_image: Downloads the image artifact and pushes it to GitHub Container Registry (on push events). # - cleanup_old_develops: Cleans up old "develop" Docker images in the registry (on push events). +# Jobs: +# - pytest: Runs unit and regression tests using pytest. +# - build_image: Builds the Docker image, tags it, and uploads it as an artifact for PRs. +# - publish_image: Downloads the image artifact and pushes it to GitHub Container Registry (on push events). +# - cleanup_old_develops: Cleans up old "develop" Docker images in the registry (on push events). + on: push: branches: [ "develop" ] @@ -20,7 +28,7 @@ on: workflow_dispatch: # allows manual triggering of the workflow env: - VERSION_PREFIX: 0.2.01. + VERSION_PREFIX: 0.2.25. VERSION_SUFFIX: -develop jobs: @@ -43,10 +51,11 @@ jobs: - name: Run unit and regression tests run: python -m pytest -v tests/ - + publish_image: runs-on: ubuntu-latest needs: pytest + needs: pytest steps: - name: Checkout uses: actions/checkout@v4 @@ -71,10 +80,35 @@ jobs: - name: Convert repository owner to lowercase run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - - name: Build image - run: docker build -t ghcr.io/${{ env.owner }}/eos_connect:develop . + # 1. Setup QEMU for ARM64 emulation + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: all + + # 2. Setup Buildx + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # 3. Log in to GitHub Container Registry + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # 4. Build & Push Multi-Arch Image + - name: Build and push multi-platform Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ env.owner }}/eos_connect:develop + ghcr.io/${{ env.owner }}/eos_connect:${{ env.VERSION }} + push: ${{ github.event_name == 'push' }} - # Only upload the image artifact for pull_request events - name: Upload Docker image as artifact if: github.event_name == 'pull_request' uses: actions/upload-artifact@v4 @@ -82,21 +116,6 @@ jobs: name: eos_connect_image-${{ env.VERSION }} path: eos_connect_${{ env.VERSION }}.tar.gz - # Only run tagging and pushing tasks for "push" events - - name: Log in to GitHub Container Registry - if: github.event_name == 'push' - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin - - - name: Tag image with develop version - if: github.event_name == 'push' - run: docker tag ghcr.io/${{ env.owner }}/eos_connect:develop ghcr.io/${{ env.owner }}/eos_connect:${{ env.VERSION }} - - - name: Push Docker image to GitHub Container Registry - if: github.event_name == 'push' - run: | - docker push ghcr.io/${{ env.owner }}/eos_connect:develop - docker push ghcr.io/${{ env.owner }}/eos_connect:${{ env.VERSION }} - cleanup_old_develops: needs: publish_image runs-on: ubuntu-latest diff --git a/.github/workflows/docker_feature.yml b/.github/workflows/docker_feature.yml index 2cb1ac16..5b9c1c4e 100644 --- a/.github/workflows/docker_feature.yml +++ b/.github/workflows/docker_feature.yml @@ -32,6 +32,28 @@ jobs: - name: Run unit and regression tests run: python -m pytest -v tests/ + build_image: + needs: pytest + pytest: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest + + - name: Run unit and regression tests + run: python -m pytest -v tests/ + build_image: needs: pytest runs-on: ubuntu-latest @@ -42,15 +64,30 @@ jobs: - name: Convert repository owner to lowercase run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - - name: Build image - run: docker build -t ghcr.io/${{ env.owner }}/eos_connect:feature . + # 1. Setup QEMU for ARM64 emulation + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: all + + # 2. Setup Buildx + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + # 3. Log in to GitHub Container Registry - name: Log in to GitHub Container Registry - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin - - - name: Tag image with develop version - run: docker tag ghcr.io/${{ env.owner }}/eos_connect:feature ghcr.io/${{ env.owner }}/eos_connect:feature_dev_${{ github.ref_name }} + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Push Docker image to GitHub Container Registry - run: | - docker push ghcr.io/${{ env.owner }}/eos_connect:feature_dev_${{ github.ref_name }} + # 4. Build & Push Multi-Arch Image + - name: Build and push multi-platform Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ env.owner }}/eos_connect:feature_dev_${{ github.ref_name }} + push: true diff --git a/.github/workflows/docker_main.yml b/.github/workflows/docker_main.yml index 1c4a8e8b..03722df7 100644 --- a/.github/workflows/docker_main.yml +++ b/.github/workflows/docker_main.yml @@ -71,8 +71,35 @@ jobs: - name: Convert repository owner to lowercase run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - - name: Build image - run: docker build -t ghcr.io/${{ env.owner }}/eos_connect:release . + # 1. Setup QEMU for ARM64 emulation + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: all + + # 2. Setup Buildx + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # 3. Log in to GitHub Container Registry + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # 4. Build & Push Multi-Arch Image + - name: Build and push multi-platform Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ env.owner }}/eos_connect:release + ghcr.io/${{ env.owner }}/eos_connect:${{ env.VERSION }} + ghcr.io/${{ env.owner }}/eos_connect:latest + push: ${{ github.event_name == 'push' }} # Only upload the image artifact for pull_request events - name: Upload Docker image as artifact diff --git a/.gitignore b/.gitignore index e367926c..4e271e42 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,7 @@ __pycache__/ src/config.bak.yaml src/config.yaml -src/json/optimize_request.json -src/json/optimize_response.json +src/json/*.json src/interfaces/__pycache__ src/interfaces/config/battery_config.json src/interfaces/config/timeofuse_config.json diff --git a/README.md b/README.md index e17799eb..8468cdf6 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # EOS Connect **EOS Connect** is an open-source tool for intelligent energy management and optimization. -It connects to various smart home platforms (like Home Assistant and OpenHAB) to monitor, forecast, and control your energy flows. -EOS Connect fetches real-time and forecast data (PV, load, prices), processes it via the [EOS (Energy Optimization System)](https://github.com/Akkudoktor-EOS/EOS), and automatically controls devices (such as Fronius inverters or batteries supported by [evcc](https://docs.evcc.io/docs/devices/meters)) to optimize your energy usage and costs. +It supports two optimization backends: the full-featured Akkudoktor EOS (default) and the lightweight EVopt (optional, very fast). +EOS Connect fetches real-time and forecast data, processes it via your chosen optimizer, and controls devices to optimize your energy usage and costs. **Key Features:** - **Automated Energy Optimization:** @@ -14,7 +14,7 @@ EOS Connect fetches real-time and forecast data (PV, load, prices), processes it - **Dynamic Web Dashboard:** Provides live monitoring, manual control, and visualization of your energy system. - **Cost Optimization:** - Aligns energy usage with dynamic electricity prices (e.g., Tibber, smartenergy.at). + Aligns energy usage with dynamic electricity prices (e.g., Tibber, smartenergy.at, Stromligning.dk). - **Flexible Configuration:** Easy to set up and extend for a wide range of energy systems and user needs. @@ -47,9 +47,12 @@ EOS Connect helps you get the most out of your solar and storage systems—wheth - [Provided Data per **EOS connect** API](#provided-data-per-eos-connect-api) - [Web API (REST/JSON)](#web-api-restjson) - [Main Endpoints](#main-endpoints) + - [Main Endpoints](#main-endpoints-1) - [MQTT - provided data and possible commands](#mqtt---provided-data-and-possible-commands) - [Published Topics](#published-topics) + - [Published Topics](#published-topics-1) - [Example Usage](#example-usage) + - [Example Usage](#example-usage-1) - [Subscribed Topics](#subscribed-topics) - [System Mode Control (`control/overall_state/set`)](#system-mode-control-controloverall_stateset) - [How to Use](#how-to-use) @@ -83,7 +86,7 @@ EOS Connect helps you get the most out of your solar and storage systems—wheth - **Dynamic Charging Curve**: - If enabled, EOS Connect automatically adjusts the maximum battery charging power based on the current state of charge (SOC). This helps to optimize battery health and efficiency by reducing charge power as the battery approaches full capacity. - **Cost and Solar Optimization**: - - Aligns energy usage with real-time electricity prices (e.g., from Tibber or [smartenergy.at](https://www.smartenergy.at/)) to minimize costs. + - Aligns energy usage with real-time electricity prices (e.g., from Tibber, [smartenergy.at](https://www.smartenergy.at/), or [Stromligning.dk](https://stromligning.dk/)) to minimize costs. - Incorporates PV forecasts to prioritize charging during periods of high solar output. - Reduces grid dependency and maximizes self-consumption by combining cost and solar production data. - **Energy Optimization Scheduling**: @@ -100,6 +103,7 @@ EOS Connect helps you get the most out of your solar and storage systems—wheth ### **Integration with External Systems** - **Home Assistant**: + - Full MQTT integration with Home Assistant Auto Discovery (enabled by default via `mqtt.ha_mqtt_auto_discovery`). - Full MQTT integration with Home Assistant Auto Discovery (enabled by default via `mqtt.ha_mqtt_auto_discovery`). - Automatically detects and configures energy system entities. - **OpenHAB**: @@ -154,13 +158,16 @@ Get up and running with EOS Connect in just a few steps! *(Or see [Installation and Running](#installation-and-running) for Docker and local options)* - **An already running instance of [EOS (Energy Optimization System)](https://github.com/Akkudoktor-EOS/EOS)** EOS Connect acts as a client and requires a reachable EOS server for optimization and control. (Or use the EOS HA addon mentioned in next step.) + EOS Connect acts as a client and requires a reachable EOS server for optimization and control. (Or use the EOS HA addon mentioned in next step.) - **Properly configured EOS for prediction** (see [EOS Configuration Requirements](#eos-configuration-requirements) below) ### 2. Install via Home Assistant Add-on - Add the [ohAnd/ha_addons](https://github.com/ohAnd/ha_addons) repository to your Home Assistant add-on store. -- [if needed] Add the [Duetting/ha_eos_addon](https://github.com/Duetting/ha_eos_addon) (or [thecem/ha_eos_addon](https://github.com/thecem/ha_eos_addon)) repository to your Home Assistant add-on store. -- Install both the **EOS Add-on** and the **EOS Connect Add-on**. +- select your preferred optimization backend: + - [if needed] Add the [Duetting/ha_eos_addon](https://github.com/Duetting/ha_eos_addon) (or [thecem/ha_eos_addon](https://github.com/thecem/ha_eos_addon)) repository to your Home Assistant add-on store. + - [if needed] Add [thecem/hassio-evopt](https://github.com/thecem/hassio-evopt) repository to your Home Assistant add-on store. ([found here](https://github.com/evcc-io/evcc/discussions/23213#3-optimizer-im-home-assistant-ha-addon-nutzen)) +- Install both the **EOS Add-on** (or **EVopt**) and the **EOS Connect Add-on**. - Configure both add-ons via the Home Assistant UI. - Start both add-ons. The EOS Connect web dashboard will be available at [http://homeassistant.local:8081](http://homeassistant.local:8081) (or your HA IP). @@ -297,11 +304,15 @@ EOS Connect supports multiple sources for solar (PV) production forecasts. You c - **Solcast** Integrates with the [Solcast API](https://solcast.com/) for high-precision solar forecasting using satellite data and machine learning models. Requires creating a rooftop site in your Solcast account and using the resource ID (not location coordinates). Free Solcast API key provides up to 10 API calls per day. **Note: EOS Connect automatically uses extended update intervals (2.5 hours) when Solcast is selected to stay within rate limits.** +- **Solcast** + Integrates with the [Solcast API](https://solcast.com/) for high-precision solar forecasting using satellite data and machine learning models. Requires creating a rooftop site in your Solcast account and using the resource ID (not location coordinates). Free Solcast API key provides up to 10 API calls per day. **Note: EOS Connect automatically uses extended update intervals (2.5 hours) when Solcast is selected to stay within rate limits.** + - **EVCC** Retrieves PV forecasts directly from an existing [EVCC](https://evcc.io/) installation via its API. This option leverages EVCC's built-in solar forecast capabilities, including its automatic scaling feature that adjusts forecasts based on your actual historical PV production data for improved accuracy. #### Energy Price Forecast Energy price forecasts are retrieved from the chosen source (e.g. tibber, Akkudoktor, Smartenergy, ...). **Note**: Prices for tomorrow are available earliest at 1 PM. Until then, today's prices are used to feed the model. +Energy price forecasts are retrieved from the chosen source (e.g. tibber, Akkudoktor, Smartenergy, ...). **Note**: Prices for tomorrow are available earliest at 1 PM. Until then, today's prices are used to feed the model. --- @@ -329,8 +340,22 @@ All endpoints return JSON and can be accessed via HTTP requests. --- +#### Main Endpoints #### Main Endpoints +| Endpoint | Method | Returns / Accepts | Description | +| ----------------------------------- | ------ | ----------------- | -------------------------------------------------------------- | +| `/json/current_controls.json` | GET | JSON | Current system control states (AC/DC charge, mode, etc.) | +| `/json/optimize_request.json` | GET | JSON | Last optimization request sent to EOS | +| `/json/optimize_response.json` | GET | JSON | Last optimization response from EOS | +| `/json/optimize_request.test.json` | GET | JSON | Test optimization request (static file) | +| `/json/optimize_response.test.json` | GET | JSON | Test optimization response (static file) | +| `/controls/mode_override` | POST | JSON (see below) | Override system mode, duration, and grid charge power | +| `/logs` | GET | JSON | Retrieve application logs with optional filtering | +| `/logs/alerts` | GET | JSON | Retrieve warning and error logs for alert system | +| `/logs/clear` | POST | JSON | Clear all stored logs from memory (file logs remain intact) | +| `/logs/alerts/clear` | POST | JSON | Clear only alert logs from memory, keeping regular logs intact | +| `/logs/stats` | GET | JSON | Get buffer usage statistics for log storage | | Endpoint | Method | Returns / Accepts | Description | | ----------------------------------- | ------ | ----------------- | -------------------------------------------------------------- | | `/json/current_controls.json` | GET | JSON | Current system control states (AC/DC charge, mode, etc.) | @@ -352,6 +377,9 @@ All endpoints return JSON and can be accessed via HTTP requests. Get current system control states and battery information. +**Response:** +Get current system control states and battery information. + **Response:** ```json { @@ -381,7 +409,8 @@ Get current system control states and battery information. "vehicleRange": 0, "vehicleOdometer": 0, "vehicleName": "", - "smartCostActive": false + "smartCostActive": false, + "planActive": false, } ] }, @@ -519,11 +548,115 @@ Get the last optimization response received from EOS. --- +
+Show Example: /json/optimize_request.json (GET) + +Get the last optimization request sent to EOS. + +**Response:** +```json +{ + "ems": { + "pv_prognose_wh": [0, 0, 0, ...], + "strompreis_euro_pro_wh": [0.0003389, 0.0003315, ...], + "einspeiseverguetung_euro_pro_wh": [0.0000794, 0.0000794, ...], + "preis_euro_pro_wh_akku": 0, + "gesamtlast": [383.316, 351.8412, ...] + }, + "pv_akku": { + "device_id": "battery1", + "capacity_wh": 22118, + "charging_efficiency": 0.93, + "discharging_efficiency": 0.93, + "max_charge_power_w": 10000, + "initial_soc_percentage": 24, + "min_soc_percentage": 5, + "max_soc_percentage": 95 + }, + "inverter": { + "device_id": "inverter1", + "max_power_wh": 10000, + "battery_id": "battery1" + }, + "eauto": { + "device_id": "ev1", + "capacity_wh": 27000, + "charging_efficiency": 0.9, + "discharging_efficiency": 0.95, + "max_charge_power_w": 7360, + "initial_soc_percentage": 50, + "min_soc_percentage": 5, + "max_soc_percentage": 100 + }, + "dishwasher": { + "device_id": "additional_load_1", + "consumption_wh": 1, + "duration_h": 1 + }, + "temperature_forecast": [9.3, 9.3,...], + "start_solution": [0, 14, ...], + "timestamp": "2025-10-14T22:21:12.128290+02:00" +} +``` +
+ +--- + +
+Show Example: /json/optimize_response.json (GET) + +Get the last optimization response received from EOS. + +**Response:** +```json +{ + "ac_charge": [0, 0, ...], + "dc_charge": [1, 1, ...], + "discharge_allowed": [0, 0, ...], + "eautocharge_hours_float": null, + "result": { + "Last_Wh_pro_Stunde": [487.02085, 387.7635, ...], + "EAuto_SoC_pro_Stunde": [50, 50, ...], + "Einnahmen_Euro_pro_Stunde": [0, 0, ...], + "Gesamt_Verluste": 817.136415028724, + "Gesamtbilanz_Euro": 0.638737006073083, + "Gesamteinnahmen_Euro": 0, + "Gesamtkosten_Euro": 0.638737006073083, + "Home_appliance_wh_per_hour": [0, 1, ...], + "Kosten_Euro_pro_Stunde": [0, 0, ...], + "Netzbezug_Wh_pro_Stunde": [0, 0, ...], + "Netzeinspeisung_Wh_pro_Stunde": [0, 0, ...], + "Verluste_Pro_Stunde": [36.6574833333333, 29.1865, ...], + "akku_soc_pro_stunde": [24, 21.632343189559, 19.7472269946047, ...], + "Electricity_price": [0.0003635, 0.0003462, ...] + }, + "eauto_obj": { + "device_id": "ev1", + "hours": 48, + "charge_array": [1, 1, ...], + "discharge_array": [1, 1, ...], + "discharging_efficiency": 0.95, + "capacity_wh": 27000, + "charging_efficiency": 0.9, + "max_charge_power_w": 7360, + "soc_wh": 13500, + "initial_soc_percentage": 50 + }, + "start_solution": [0, 14, ...], + "washingstart": 23, + "timestamp": "2025-10-14T22:21:12.128796+02:00" +} +``` +
+ +--- +
Show Example: /controls/mode_override (POST) Override the system mode, duration, and grid charge power. +**Request Payload:** **Request Payload:** ```json { @@ -547,6 +680,17 @@ Override the system mode, duration, and grid charge power. "end_time": "2024-06-01T14:00:00+02:00" } } + { + "status": "success", + "message": "Mode override applied", + "applied_settings": { + "mode": 1, + "mode_name": "ChargeFromGrid", + "duration": "02:00", + "grid_charge_power": 2000, + "end_time": "2024-06-01T14:00:00+02:00" + } + } ``` - On error: ```json @@ -554,6 +698,10 @@ Override the system mode, duration, and grid charge power. "error": "Invalid mode value", "details": "Mode must be between 0 and 4" } + { + "error": "Invalid mode value", + "details": "Mode must be between 0 and 4" + } ``` **System Modes (`mode` field):** @@ -704,6 +852,163 @@ Clear all stored logs from memory (file logs remain intact). Clear only alert logs from memory, keeping regular logs intact. +**Response:** +- On success: + ```json + { "status": "success", "message": "Alert logs cleared" } + ``` +- On error: + ```json + { "error": "Failed to clear alert logs" } + ``` + +**Note:** This only clears the dedicated alert buffer (2000 entries), leaving the main log buffer untouched. +| Mode Name | Mode Number | Description | +| ----------------------------- | ----------- | ------------------------------------------- | +| `Auto` | -2 | Fully automatic optimization (default mode) | +| `StartUp` | -1 | System startup state | +| `Charge from Grid` | 0 | Force battery charging from the grid | +| `Avoid Discharge` | 1 | Prevent battery discharge | +| `Discharge Allowed` | 2 | Allow battery discharge | +| `Avoid Discharge EVCC FAST` | 3 | Avoid discharge with EVCC fast charge | +| `Avoid Discharge EVCC PV` | 4 | Avoid discharge with EVCC PV mode | +| `Avoid Discharge EVCC MIN+PV` | 5 | Avoid discharge with EVCC MIN+PV mode | + +
+ +--- + +
+Show Example: /logs (GET) + +Retrieve application logs with optional filtering. + +**Query Parameters:** +- `level`: Filter by log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`) +- `limit`: Maximum number of records to return (default: 100) +- `since`: ISO timestamp to get logs since that time + +**Examples:** +- Get last 50 logs: `GET /logs?limit=50` +- Get only error logs: `GET /logs?level=ERROR` +- Get logs since 1 hour ago: `GET /logs?since=2024-06-01T11:00:00Z` + +**Response:** +```json +{ + "logs": [ + { + "timestamp": "2024-06-01T12:00:00.123456", + "level": "INFO", + "message": "[Main] Starting optimization run", + "module": "__main__", + "funcName": "run_optimization", + "lineno": 542, + "severity": 20 + } + ], + "total_count": 1, + "timestamp": "2024-06-01T12:00:00+02:00", + "filters_applied": { + "level": null, + "limit": 100, + "since": null + } +} +``` +
+ +--- + +
+Show Example: /logs/alerts (GET) + +Retrieve warning and error logs for alert system. + +**Response:** +```json +{ + "alerts": [ + { + "timestamp": "2024-06-01T12:00:00.123456", + "level": "WARNING", + "message": "[Battery] SOC exceeded maximum threshold", + "module": "__main__", + "funcName": "setting_control_data", + "lineno": 234, + "severity": 30 + } + ], + "grouped_alerts": { + "WARNING": [ ... ], + "ERROR": [ ... ], + "CRITICAL": [ ... ] + }, + "alert_counts": { + "WARNING": 1, + "ERROR": 0, + "CRITICAL": 0 + }, + "timestamp": "2024-06-01T12:00:00+02:00" +} +``` +
+ +--- + +
+Show Example: /logs/stats (GET) + +Get buffer usage statistics for log storage monitoring. + +**Response:** +```json +{ + "buffer_stats": { + "main_buffer": { + "current_size": 3456, + "max_size": 5000, + "usage_percent": 69.1 + }, + "alert_buffer": { + "current_size": 23, + "max_size": 2000, + "usage_percent": 1.2 + }, + "alert_levels": ["WARNING", "ERROR", "CRITICAL"] + }, + "timestamp": "2024-06-01T12:00:00+02:00" +} +``` +
+ +--- + +
+Show Example: /logs/clear (POST) + +Clear all stored logs from memory (file logs remain intact). + +**Response:** +- On success: + ```json + { "status": "success", "message": "Logs cleared" } + ``` +- On error: + ```json + { "error": "Failed to clear logs" } + ``` + +**Note:** This clears both the main log buffer (5000 entries) and the alert buffer (2000 entries). +
+ +--- + +
+Show Example: /logs/alerts/clear (POST) + +Clear only alert logs from memory, keeping regular logs intact. + **Response:** - On success: ```json @@ -735,6 +1040,16 @@ Clear only alert logs from memory, keeping regular logs intact. `POST http://localhost:8081/logs/clear` - **Clear only alerts:** `POST http://localhost:8081/logs/alerts/clear` +- **Monitor application logs:** + `GET http://localhost:8081/logs?level=ERROR&limit=20` +- **Get system alerts:** + `GET http://localhost:8081/logs/alerts` +- **Get log buffer statistics:** + `GET http://localhost:8081/logs/stats` +- **Clear memory logs:** + `POST http://localhost:8081/logs/clear` +- **Clear only alerts:** + `POST http://localhost:8081/logs/alerts/clear` You can use `curl`, Postman, or any HTTP client to interact with these endpoints. @@ -770,6 +1085,39 @@ curl -X POST "http://localhost:8081/controls/mode_override" \ - **Performance**: Memory-based access provides fast response times - **Monitoring**: Use `/logs/stats` to monitor buffer usage and plan capacity +The logging API enables real-time monitoring, alerting systems, and debugging without affecting the persistent file-based logging system. +**Examples using curl:** +```bash +# Get last 10 error logs +curl "http://localhost:8081/logs?level=ERROR&limit=10" + +# Get current system alerts +curl "http://localhost:8081/logs/alerts" + +# Get log buffer usage statistics +curl "http://localhost:8081/logs/stats" + +# Clear all memory logs +curl -X POST "http://localhost:8081/logs/clear" + +# Clear only alert logs +curl -X POST "http://localhost:8081/logs/alerts/clear" + +# Override system mode +curl -X POST "http://localhost:8081/controls/mode_override" \ + -H "Content-Type: application/json" \ + -d '{"mode": 1, "duration": "02:00", "grid_charge_power": 2.0}' +``` + +**Memory Log System Notes:** +- **Main buffer**: Stores the last 5000 log entries (all levels mixed) +- **Alert buffer**: Stores the last 2000 alert entries (WARNING/ERROR/CRITICAL only) +- **Persistent storage**: File-based logs are not affected by memory operations +- **Timezone aware**: All timestamps use the configured timezone +- **Thread-safe**: Safe for concurrent access from multiple clients +- **Performance**: Memory-based access provides fast response times +- **Monitoring**: Use `/logs/stats` to monitor buffer usage and plan capacity + The logging API enables real-time monitoring, alerting systems, and debugging without affecting the persistent file-based logging system.
@@ -790,6 +1138,33 @@ EOS Connect publishes a wide range of real-time system data and control states t --- #### Published Topics +#### Published Topics + +| Topic Suffix | Full Topic Example | Payload Type / Example | Description | +| --------------------------------------------- | ---------------------------------------------------------------- | -------------------------- | ----------------------------------------------------------- | +| `optimization/state` | `myhome/eos_connect/optimization/state` | String (`"ok"`, `"error"`) | Current optimization request state | +| `optimization/last_run` | `myhome/eos_connect/optimization/last_run` | ISO timestamp | Timestamp of the last optimization run | +| `optimization/next_run` | `myhome/eos_connect/optimization/next_run` | ISO timestamp | Timestamp of the next scheduled optimization run | +| `control/override_charge_power` | `myhome/eos_connect/control/override_charge_power` | Integer (W) | Override charge power | +| `control/override_active` | `myhome/eos_connect/control/override_active` | Boolean (`true`/`false`) | Whether override is active | +| `control/override_end_time` | `myhome/eos_connect/control/override_end_time` | ISO timestamp | When override ends | +| `control/overall_state` | `myhome/eos_connect/control/overall_state` | Integer (see mode table) | Current overall system mode - see System Mode Control below | +| `control/eos_homeappliance_released` | `myhome/eos_connect/control/eos_homeappliance_released` | Boolean | Home appliance released flag | +| `control/eos_homeappliance_start_hour` | `myhome/eos_connect/control/eos_homeappliance_start_hour` | Integer (hour) | Home appliance start hour | +| `battery/soc` | `myhome/eos_connect/battery/soc` | Float (%) | Battery state of charge | +| `battery/remaining_energy` | `myhome/eos_connect/battery/remaining_energy` | Integer (Wh) | Usable battery capacity | +| `battery/dyn_max_charge_power` | `myhome/eos_connect/battery/dyn_max_charge_power` | Integer (W) | Dynamic max charge power | +| `inverter/special/temperature_inverter` | `myhome/eos_connect/inverter/special/temperature_inverter` | Float (°C) | Inverter temperature (if Fronius V1/V2) | +| `inverter/special/temperature_ac_module` | `myhome/eos_connect/inverter/special/temperature_ac_module` | Float (°C) | AC module temperature (if Fronius V1/V2) | +| `inverter/special/temperature_dc_module` | `myhome/eos_connect/inverter/special/temperature_dc_module` | Float (°C) | DC module temperature (if Fronius V1/V2) | +| `inverter/special/temperature_battery_module` | `myhome/eos_connect/inverter/special/temperature_battery_module` | Float (°C) | Battery module temperature (if Fronius V1/V2) | +| `inverter/special/fan_control_01` | `myhome/eos_connect/inverter/special/fan_control_01` | Integer | Fan control 1 (if Fronius V1/V2) | +| `inverter/special/fan_control_02` | `myhome/eos_connect/inverter/special/fan_control_02` | Integer | Fan control 2 (if Fronius V1/V2) | +| `status` | `myhome/eos_connect/status` | String (`"online"`) | Always set to `"online"` | +| `control/eos_ac_charge_demand` | `myhome/eos_connect/control/eos_ac_charge_demand` | Integer (W) | AC charge demand | +| `control/eos_dc_charge_demand` | `myhome/eos_connect/control/eos_dc_charge_demand` | Integer (W) | DC charge demand | +| `control/eos_discharge_allowed` | `myhome/eos_connect/control/eos_discharge_allowed` | Boolean | Discharge allowed | + | Topic Suffix | Full Topic Example | Payload Type / Example | Description | | --------------------------------------------- | ---------------------------------------------------------------- | -------------------------- | ----------------------------------------------------------- | @@ -820,6 +1195,7 @@ EOS Connect publishes a wide range of real-time system data and control states t --- +#### Example Usage #### Example Usage - **Monitor battery SOC in Home Assistant:** @@ -838,6 +1214,7 @@ You can use any MQTT client, automation platform, or dashboard tool to subscribe **Notes:** - The `` is set in your configuration file (see `config.yaml`). - Some topics (e.g., inverter special values) are only published if the corresponding hardware is present and enabled. +- Some topics (e.g., inverter special values) are only published if the corresponding hardware is present and enabled. - All topics are published with real-time updates as soon as new data is available. @@ -857,6 +1234,11 @@ EOS Connect can be remotely controlled via MQTT by publishing messages to specif ### Subscribed Topics +| Topic Suffix | Full Topic Example | Expected Payload | Description / Effect | +| ----------------------------------- | ------------------------------------------------------ | ------------------------ | -------------------------------------------------- | +| `control/overall_state/set` | `myhome/eos_connect/control/overall_state/set` | Integer or string (mode) | Changes the system mode (see table below) | +| `control/override_remain_time/set` | `myhome/eos_connect/control/override_remain_time/set` | String `"HH:MM"` | Sets the override duration (e.g., `"02:00"`) | +| `control/override_charge_power/set` | `myhome/eos_connect/control/override_charge_power/set` | Integer (watts) | Sets the override grid charge power (e.g., `2000`) | | Topic Suffix | Full Topic Example | Expected Payload | Description / Effect | | ----------------------------------- | ------------------------------------------------------ | ------------------------ | -------------------------------------------------- | | `control/overall_state/set` | `myhome/eos_connect/control/overall_state/set` | Integer or string (mode) | Changes the system mode (see table below) | @@ -870,6 +1252,13 @@ EOS Connect can be remotely controlled via MQTT by publishing messages to specif You can set the system mode by publishing either the **mode name** (string) or the **mode number** (integer). **Only the following values are accepted:** +| Mode Name | Mode Number | Description | +| ---------------- | ----------- | ------------------------------------------- | +| `Auto` | 0 | Fully automatic optimization (default mode) | +| `ChargeFromGrid` | 1 | Force battery charging from the grid | +| `Discharge` | 2 | Force battery discharge | +| `Idle` | 3 | No charging or discharging | +| `PVOnly` | 4 | Charge battery only from PV (solar) | | Mode Name | Mode Number | Description | | ---------------- | ----------- | ------------------------------------------- | | `Auto` | 0 | Fully automatic optimization (default mode) | @@ -1082,6 +1471,55 @@ Guidelines - Document new config keys / API / MQTT topics - Prefer clarity over cleverness +Thanks for contributing! +We welcome PRs. Keep main clean, iterate fast on develop. + +Branch roles +- main: stable, tagged releases only (comes from develop). +- develop: integration branch (target of normal PRs). +- feature_ or feature_-: new code (from develop). +- bugfix_-: fix for something already in develop. +- hotfix_-: urgent production fix (from main → PR to main → merge back into develop). +- issue--: automatically created from a GitHub issue (allowed and recommended). + +You can create a branch manually or use GitHub’s "Create branch" button on an issue, which will name it like `issue-123-description`. This is fully supported and recommended for traceability. + +Flow +1. Update local: git fetch origin && git switch develop && git pull --ff-only +2. Create branch: git switch -c feature/better-forecast +3. Code + tests + docs (README / CONFIG_README / MQTT if behavior changes) +4. Run formatting, lint, tests + - Ensure all Python files are formatted with [Black](https://black.readthedocs.io/en/stable/) (`black .`) + - **Tip for VS Code users:** Install the [Black Formatter extension](https://github.com/microsoft/vscode-black-formatter) for automatic formatting on save. (// VS Code settings.json "[python]": { "editor.formatOnSave": true }) + - Run [pylint](https://pylint.pycqa.org/) and ensure a score of **9.0 or higher** for all files (`pylint src/`) + - tests - see info at guidelines below +5. Rebase before PR: git fetch origin && git rebase origin/develop +6. Push: git push -u origin feature/better-forecast +7. Open PR → base: develop (link issues: Closes #123) +8. Keep PR focused; squash or rebase merge (no merge commits) + +Commits (Conventional) +feat: add battery forecast smoothing +fix: correct negative PV handling +docs: update MQTT topic table + +Hotfix +git switch main +git pull --ff-only +git switch -c hotfix/overrun-calc +...fix... +PR → main, tag release, then: git switch develop && git merge --ff-only main + +Guidelines +- One logical change per PR +- Add/adjust tests for logic changes + - Use [pytest](https://docs.pytest.org/) for all unit and integration tests. + - Place tests in the `tests/` directory, organized to mirror the structure of the `src/` directory: + - Create a subfolder for each source module or feature (e.g., if your code is in `src/interfaces/mqtt_interface.py`, place tests in `tests/interfaces/test_mqtt_interface.py`). + - Name test files as `test_.py` (e.g., `test_mqtt_interface.py` for `mqtt_interface.py`). +- Document new config keys / API / MQTT topics +- Prefer clarity over cleverness + Thanks for contributing! ## Glossary @@ -1089,6 +1527,20 @@ Thanks for contributing!
Show Glossary +| Term / Abbreviation | Meaning | +| ------------------- | --------------------------------------------------------------------------------------------------------------------- | +| **EOS** | Energy Optimization System – the [backend optimizer](https://github.com/Akkudoktor-EOS/EOS) this project connects to. | +| **SOC** | State of Charge – the current charge level of your battery, usually in percent (%). | +| **PV** | Photovoltaic – refers to solar panels and their energy production. | +| **EV** | Electric Vehicle. | +| **EVCC** | Electric Vehicle Charge Controller – [software](https://github.com/evcc-io/evcc)/hardware for managing EV charging. | +| **HA** | [Home Assistant](https://www.home-assistant.io/) – popular open-source smart home platform. | +| **OpenHAB** | Another [open-source](https://www.openhab.org/) smart home platform. | +| **MQTT** | Lightweight messaging protocol for IoT and smart home integration. | +| **API** | Application Programming Interface – allows other software to interact with EOS Connect. | +| **Add-on** | A packaged extension for Home Assistant, installable via its UI. | +| **Grid** | The public electricity network. | +| **Dashboard** | The web interface provided by EOS Connect for monitoring and control. | | Term / Abbreviation | Meaning | | ------------------- | --------------------------------------------------------------------------------------------------------------------- | | **EOS** | Energy Optimization System – the [backend optimizer](https://github.com/Akkudoktor-EOS/EOS) this project connects to. | @@ -1108,4 +1560,4 @@ Thanks for contributing! ## License -This project is licensed under the MIT License. See the LICENSE file for more details. \ No newline at end of file +This project is licensed under the MIT License. See the LICENSE file for more details. diff --git a/src/CONFIG_README.md b/src/CONFIG_README.md index 5464ccf8..82d7edd6 100644 --- a/src/CONFIG_README.md +++ b/src/CONFIG_README.md @@ -9,13 +9,16 @@ - [Electricity Price Configuration](#electricity-price-configuration) - [Battery Configuration](#battery-configuration) - [PV Forecast Configuration](#pv-forecast-configuration) - - [Parameters](#parameters) + - [PV Forecast Configuration](#pv-forecast-configuration-1) + - [Parameter Details](#parameter-details) + - [Example Config Entry](#example-config-entry) + - [Notes](#notes) - [Inverter Configuration](#inverter-configuration) - [EVCC Configuration](#evcc-configuration) - [MQTT Configuration](#mqtt-configuration) - - [Parameters](#parameters-1) + - [Parameters](#parameters) - [Other Configuration Settings](#other-configuration-settings) - - [Notes](#notes) + - [Notes](#notes-1) - [Config examples](#config-examples) - [Full Config Example (will be generated at first startup)](#full-config-example-will-be-generated-at-first-startup) - [Minimal possible Config Example](#minimal-possible-config-example) @@ -77,11 +80,14 @@ A default config file will be created with the first start, if there is no `conf ### EOS Server Configuration +- **`eos.source`**: + EOS server source - eos_server, evopt, default (default uses eos_server) + - **`eos.server`**: - EOS server address (e.g., `192.168.1.94`). (Mandatory) + EOS or EVopt server address (e.g., `192.168.1.94`). (Mandatory) - **`eos.port`**: - Port for the EOS server. Default: `8503`. (Mandatory) + port for EOS server (8503) or EVopt server (7050) - default: `8503` (Mandatory) - **`timeout`**: Timeout for EOS optimization requests, in seconds. Default: `180`. (Mandatory) @@ -93,11 +99,21 @@ A default config file will be created with the first start, if there is no `conf **Important: All price values must use the same base - either all prices include taxes and fees, or all prices exclude taxes and fees. Mixing different bases will lead to incorrect optimization results.** - **`price.source`**: - Data source for electricity prices. Possible values: `tibber`, `smartenergy_at`,`fixed_24h`,`default` (default uses akkudoktor API). + Data source for electricity prices. Possible values: `tibber`, `smartenergy_at`, `stromligning`, `fixed_24h`, `default` (default uses akkudoktor API). - **`price.token`**: Token for accessing electricity price data. (If not needed, set to `token: ""`) + When used with **Tibber**: + + Provide your token + + When used with **Strømligning**: + - Use the format: `supplierId/productId[/customerGroupId]` (customer group is optional). + - Example with customer group: `radius_c/velkommen_gron_el/c` + - Example without customer group: `nke-elnet/forsyningen` + - You can find the appropriate values on the [Strømligning live page](https://stromligning.dk/live) or via the [API docs](https://stromligning.dk/api/docs/swagger.json#/Prices/get_api_prices). On the site, select the desired "netselskab" and supplier/product; copy the `netselskab` part to `supplierId`, the `produkt` part to `productId`, and the optional group to `customerGroupId`. + - **`fixed_price_adder_ct`**: Describes the fixed cost addition in ct per kWh. Only applied to source default (akkudoktor). @@ -173,76 +189,98 @@ A default config file will be created with the first start, if there is no `conf ### PV Forecast Configuration This section contains two subsections: -- `pv_forecast_source` -- `pv_forecast` -**Important:** All supported PV forecast providers (akkudoktor, openmeteo, openmeteo_local, forecast_solar, evcc) use the same azimuth convention where **0° = South** and **negative values = East**. No conversion is needed when switching between providers. +### PV Forecast Configuration -`pv_forecast_source` section declares the provider of solar forecast that should be used. Available providers are -- `akkudoktor` - https://api.akkudoktor.net/ - direct request and results -- `openmeteo` - https://open-meteo.com/en/docs - uses the [open-meteo-solar-forecast](https://github.com/rany2/open-meteo-solar-forecast) (no horizon possible by the lib at this time) -- `openmeteo_local` - https://open-meteo.com/en/docs - gathering radiation and cloudcover data and calculating locally with an own model - still in dev to improve the calculation -- `forecast_solar` - https://doc.forecast.solar/api - direct request and results -- `solcast` - https://solcast.com/ - high-precision solar forecasting using satellite data and machine learning models. Requires creating a rooftop site in your Solcast account and using the resource_id (not location coordinates). Free API key provides up to 10 calls per day. -- `evcc` - retrieves forecasts from an existing EVCC installation via API - requires EVCC section to be configured -default is uses akkudoktor +Each entry in `pv_forecast` must follow these rules, depending on the selected `pv_forecast_source`: -**Temperature Forecasts**: EOS Connect also fetches temperature forecasts to improve optimization accuracy, as temperature affects battery efficiency and energy consumption patterns. When using provider-specific configurations (akkudoktor, openmeteo, etc.), temperature data is automatically retrieved using the same geographical coordinates. When using EVCC, localized temperature forecasts require at least one PV configuration entry with coordinates. +| Parameter | Required for Source(s) | Type/Format | Default/Notes | +| -------------------- | ---------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------- | +| `name` | all | string | User-defined identifier. Must be unique if multiple installations. | +| `lat` | all except `evcc` | float | Latitude of PV installation. Required for temperature forecasts. | +| `lon` | all except `evcc` | float | Longitude of PV installation. Required for temperature forecasts. | +| `azimuth` | all except `solcast`, `evcc` | int/float | Required. For `solcast`/`evcc`, defaults to `0` if missing. | +| `tilt` | all except `solcast`, `evcc` | int/float | Required. For `solcast`/`evcc`, defaults to `0` if missing. | +| `power` | all except `evcc`, `solcast` | int/float | Required. For `evcc`/`solcast`, set to `0` (dummy value for temperature forecast). | +| `powerInverter` | all except `evcc`, `forecast_solar`, `solcast` | int/float | Required. For `evcc`, `forecast_solar`, `solcast`, set to `0` (dummy value for temperature forecast). | +| `inverterEfficiency` | all except `evcc`, `forecast_solar`, `solcast` | float | Required. For `evcc`, `forecast_solar`, `solcast`, set to `0` (dummy value for temperature forecast). | +| `horizon` | `openmeteo_local`, `forecast_solar` | list or string | Mandatory. If missing, defaults to `[0]*36` for `openmeteo_local`, `[0]*24` for `forecast_solar`. | +| `resource_id` | `solcast` | string | Required for Solcast. Must be set in each entry when using Solcast as the source. | -`pv_forecast` section allows you to define multiple PV forecast entries, each distinguished by a user-given name. Below is an example of a default PV forecast configuration: +#### Parameter Details -```yaml -pv_forecast_source: - source: akkudoktor # data source for solar forecast providers akkudoktor, openmeteo, openmeteo_local, forecast_solar, evcc, solcast, default (default uses akkudoktor) - api_key: "" # API key for Solcast (required only when source is 'solcast') -pv_forecast: - - name: myPvInstallation1 # User-defined identifier for the PV installation, must be unique if you use multiple installations - lat: 47.5 # Latitude for PV forecast @ Akkudoktor API - lon: 8.5 # Longitude for PV forecast @ Akkudoktor API - azimuth: 90.0 # Azimuth for PV forecast @ Akkudoktor API - tilt: 30.0 # Tilt for PV forecast @ Akkudoktor API - power: 4600 # Power for PV forecast @ Akkudoktor API - powerInverter: 5000 # Power Inverter for PV forecast @ Akkudoktor API - inverterEfficiency: 0.9 # Inverter Efficiency for PV forecast @ Akkudoktor API - horizon: 10,20,10,15 # Horizon to calculate shading, up to 360 values to describe the shading situation for your PV. - resource_id: "" # Resource ID for Solcast (required only when source is 'solcast') -``` +- **name**: + User-defined identifier for the PV installation. Must be unique if you use multiple installations. -#### Parameters -- **`name`**: - A user-defined identifier for the PV installation. Must be unique if you use multiple installations. +- **lat/lon**: + Latitude and longitude for the PV installation. Required for all sources except `evcc`. + *For Solcast, these are still required for temperature forecasts.* -- **`lat`**: - Latitude for the PV forecast. +- **azimuth**: + Azimuth angle in degrees. Required for all sources except `solcast` and `evcc`. + *If missing for `solcast` or `evcc`, defaults to `0`.* -- **`lon`**: - Longitude for the PV forecast. +- **tilt**: + Tilt angle in degrees. Required for all sources except `solcast` and `evcc`. + *If missing for `solcast` or `evcc`, defaults to `0`.* -- **`azimuth`**: - Azimuth angle for the PV forecast in degrees. **All supported forecast providers use the same solar/PV industry standard convention:** - - **0° = South** (optimal orientation for Northern Hemisphere) - - **90° = West** - - **180° = North** - - **-90° = East** (negative values for east-facing) - - **Example orientations:** A south-facing roof would use `azimuth: 0`, while a garage facing southeast would use `azimuth: -45`, and a carport facing west would use `azimuth: 90`. An east-facing installation would use `azimuth: -90`. +- **power**: + PV installation power in watts. Required for all sources except `evcc` and `solcast`. + *For `evcc` and `solcast`, set to `0` (dummy value for temperature forecast).* + +- **powerInverter**: + Inverter power in watts. Required for all sources except `evcc`, `forecast_solar`, and `solcast`. + *For `evcc`, `forecast_solar`, and `solcast`, set to `0` (dummy value for temperature forecast).* -- **`tilt`**: - Tilt angle for the PV forecast. +- **inverterEfficiency**: + Inverter efficiency as a decimal between `0` and `1`. Required for all sources except `evcc`, `forecast_solar`, and `solcast`. + *For `evcc`, `forecast_solar`, and `solcast`, set to `0` (dummy value for temperature forecast).* + +- **horizon**: + Shading situation for the PV installation. + - Mandatory for `openmeteo_local` and `forecast_solar`. + - If missing, defaults to `[0]*36` for `openmeteo_local`, `[0]*24` for `forecast_solar`. + - Can be a comma-separated string or a list of values. + +- **resource_id**: + Required only for `solcast`. + - Must be set in each entry when using Solcast as the source. + - Used to identify the rooftop site in your Solcast account. + +#### Example Config Entry + +```yaml +pv_forecast: + - name: Garden + lat: 52.5200 + lon: 13.4050 + azimuth: 13 + tilt: 31 + power: 860 + powerInverter: 800 + inverterEfficiency: 0.95 + horizon: 0,0,0,0,0,0,0,0,50,70,0,0,0,0,0,0,0,0 + # resource_id: "your_solcast_resource_id" # Only for Solcast +``` -- **`power`**: - The power of the PV installation, in watts (W). +#### Notes -- **`powerInverter`**: - The power of the inverter, in watts (W). +- For `evcc` and `solcast`, dummy values are set for `power`, `powerInverter`, and `inverterEfficiency` to enable temperature forecasts. +- For `openmeteo_local` and `forecast_solar`, ensure `horizon` is provided or defaults will be used. +- For `solcast`, both `api_key` (in `pv_forecast_source`) and `resource_id` (in each `pv_forecast` entry) are required. -- **`inverterEfficiency`**: - The efficiency of the inverter, as a decimal value between `0` and `1`. +Refer to this table and details when editing your `config.yaml` and for troubleshooting configuration errors. +- **`api_key`** (in `pv_forecast_source`): Required. Your Solcast API key obtained from your Solcast account. +- **`resource_id`** (in each `pv_forecast` entry): Required. The resource ID from your Solcast rooftop site configuration. +- **Location parameters for temperature forecasts**: While `azimuth`, `tilt`, and `horizon` are configured in your Solcast dashboard and ignored by EOS Connect, **`lat` and `lon` are still required** for fetching temperature forecasts that EOS needs for accurate optimization calculations. +- **`power`, `powerInverter`, `inverterEfficiency`**: Still required for system scaling and efficiency calculations. -- **`horizon`**: - (Optional) A list of up to 36 values describing the shading situation for the PV installation. The list always covers 360° – 4 entries will represent 90° steps, e.g. - - 10,20,10,15 – 0–90° is shadowed if sun elevation is below 10°, and so on. - - 0,0,0,0,0,0,0,0,50,70,0,0,0,0,0,0,0,0 – 18 entries → 20° steps; here, 180°–200° requires 50° of sun elevation, otherwise the panel is shadowed. +**Setting up Solcast:** +1. Create a free account at [solcast.com](https://solcast.com/) +2. Configure a "Rooftop Site" with your PV system details (location, tilt, azimuth, capacity) +3. Copy the Resource ID from your rooftop site +4. Get your API key from the account settings +5. Use these values in your EOS Connect configuration (including lat/lon for temperature forecasts) - **`resource_id`**: (Solcast only) The resource ID from your Solcast rooftop site configuration. Required when using Solcast as the PV forecast source. Not used by other providers. @@ -379,13 +417,14 @@ load: additional_load_1_consumption: 1500 # consumption for additional load 1 in Wh - default: 0 (If not needed set to `additional_load_1_sensor: ""`) # EOS server configuration eos: + source: eos_server # EOS server source - eos_server, evopt, default (default uses eos_server) server: 192.168.1.94 # EOS server address port: 8503 # port for EOS server - default: 8503 timeout: 180 # timeout for EOS optimize request in seconds - default: 180 # Electricity price configuration price: - source: default # data source for electricity price tibber, smartenergy_at, fixed_24h, default (default uses akkudoktor) - token: tibberBearerToken # Token for electricity price + source: default # data source for electricity price tibber, smartenergy_at, stromligning, fixed_24h, default (default uses akkudoktor) + token: tibberBearerToken # Token for electricity price (for Stromligning use supplierId/productId[/customerGroupId]) fixed_price_adder_ct: 2.5 # Describes the fixed cost addition in ct per kWh. relative_price_multiplier: 0.05 # Applied to (base energy price + fixed_price_adder_ct). Use a decimal (e.g., 0.05 for 5%). fixed_24h_array: 10.41, 10.42, 10.42, 10.42, 10.42, 23.52, 28.17, 28.17, 28.17, 28.17, 28.17, 23.52, 23.52, 23.52, 23.52, 28.17, 28.17, 34.28, 34.28, 34.28, 34.28, 34.28, 28.17, 23.52 # 24 hours array with fixed prices over the day @@ -409,19 +448,23 @@ battery: pv_forecast_source: source: akkudoktor # data source for solar forecast providers akkudoktor, openmeteo, openmeteo_local, forecast_solar, evcc, solcast, default (default uses akkudoktor) api_key: "" # API key for Solcast (required only when source is 'solcast') + source: akkudoktor # data source for solar forecast providers akkudoktor, openmeteo, openmeteo_local, forecast_solar, evcc, solcast, default (default uses akkudoktor) + api_key: "" # API key for Solcast (required only when source is 'solcast') # List of PV forecast configurations. Add multiple entries as needed. # See Akkudtor API (https://api.akkudoktor.net/#/pv%20generation%20calculation/getForecast) for more details. +# See Akkudtor API (https://api.akkudoktor.net/#/pv%20generation%20calculation/getForecast) for more details. pv_forecast: - name: myPvInstallation1 # User-defined identifier for the PV installation, have to be unique if you use more installations - lat: 47.5 # Latitude for PV forecast @ Akkudoktor API - lon: 8.5 # Longitude for PV forecast @ Akkudoktor API - azimuth: 90.0 # Azimuth for PV forecast @ Akkudoktor API - tilt: 30.0 # Tilt for PV forecast @ Akkudoktor API - power: 4600 # Power for PV forecast @ Akkudoktor API - powerInverter: 5000 # Power Inverter for PV forecast @ Akkudoktor API - inverterEfficiency: 0.9 # Inverter Efficiency for PV forecast @ Akkudoktor API + lat: 52.5200 # Latitude for PV forecast + lon: 13.4050 # Longitude for PV forecast + azimuth: 90.0 # Azimuth for PV forecast + tilt: 30.0 # Tilt for PV forecast + power: 4600 # Power for PV forecast + powerInverter: 5000 # Power Inverter for PV forecast + inverterEfficiency: 0.9 # Inverter Efficiency for PV forecast horizon: 10,20,10,15 # Horizon to calculate shading up to 360 values to describe shading situation for your PV. resource_id: "" # Resource ID for Solcast (required only when source is 'solcast') + resource_id: "" # Resource ID for Solcast (required only when source is 'solcast') # Inverter configuration inverter: type: default # Type of inverter - fronius_gen24, fronius_gen24_legacy, evcc, default (default will disable inverter control - only displaying the target state) - preset: default @@ -460,12 +503,14 @@ load: car_charge_load_sensor: Wallbox_Power # item / entity for wallbox power data in watts. (If not needed, set to `load.car_charge_load_sensor: ""`) # EOS server configuration eos: + source: eos_server # EOS server source - eos_server, evopt, default (default uses eos_server) server: 192.168.1.94 # EOS server address port: 8503 # port for EOS server - default: 8503 timeout: 180 # timeout for EOS optimize request in seconds - default: 180 # Electricity price configuration price: - source: default # data source for electricity price tibber, smartenergy_at, fixed_24h, default (default uses akkudoktor) + source: default # data source for electricity price tibber, smartenergy_at, stromligning, fixed_24h, default (default uses akkudoktor) + token: "" # Provide Tibber token or Stromligning supplierId/productId[/customerGroupId] when needed fixed_price_adder_ct: 0 # Describes the fixed cost addition in ct per kWh. relative_price_multiplier: 0 # Applied to (base energy price + fixed_price_adder_ct). Use a decimal (e.g., 0.05 for 5%). # battery configuration @@ -483,17 +528,20 @@ battery: pv_forecast_source: source: akkudoktor # data source for solar forecast providers akkudoktor, openmeteo, openmeteo_local, forecast_solar, evcc, solcast, default (default uses akkudoktor) api_key: "" # API key for Solcast (required only when source is 'solcast') + source: akkudoktor # data source for solar forecast providers akkudoktor, openmeteo, openmeteo_local, forecast_solar, evcc, solcast, default (default uses akkudoktor) + api_key: "" # API key for Solcast (required only when source is 'solcast') # List of PV forecast configurations. Add multiple entries as needed. # See Akkudtor API (https://api.akkudoktor.net/#/pv%20generation%20calculation/getForecast) for more details. +# See Akkudtor API (https://api.akkudoktor.net/#/pv%20generation%20calculation/getForecast) for more details. pv_forecast: - name: myPvInstallation1 # User-defined identifier for the PV installation, have to be unique if you use more installations - lat: 47.5 # Latitude for PV forecast @ Akkudoktor API - lon: 8.5 # Longitude for PV forecast @ Akkudoktor API - azimuth: 90.0 # Azimuth for PV forecast @ Akkudoktor API - tilt: 30.0 # Tilt for PV forecast @ Akkudoktor API - power: 4600 # Power for PV forecast @ Akkudoktor API - powerInverter: 5000 # Power Inverter for PV forecast @ Akkudoktor API - inverterEfficiency: 0.9 # Inverter Efficiency for PV forecast @ Akkudoktor API + lat: 52.5200 # Latitude for PV forecast + lon: 13.4050 # Longitude for PV forecast + azimuth: 90.0 # Azimuth for PV forecast + tilt: 30.0 # Tilt for PV forecast + power: 4600 # Power for PV forecast + powerInverter: 5000 # Power Inverter for PV forecast + inverterEfficiency: 0.9 # Inverter Efficiency for PV forecast horizon: 10,20,10,15 # Horizon to calculate shading up to 360 values to describe shading situation for your PV. # Inverter configuration inverter: @@ -521,8 +569,8 @@ pv_forecast_source: source: evcc # Use EVCC for PV forecasts pv_forecast: - name: "Location for Temperature" # At least one entry needed for temperature forecasts - lat: 47.5 # Required for temperature forecasts used by EOS optimization - lon: 8.5 # Required for temperature forecasts used by EOS optimization + lat: 52.5200 # Required for temperature forecasts used by EOS optimization + lon: 13.4050 # Required for temperature forecasts used by EOS optimization # Other parameters (azimuth, tilt, power, etc.) not used for PV forecasts but can be included # EVCC configuration - REQUIRED when using evcc as pv_forecast_source evcc: @@ -532,6 +580,7 @@ evcc: In this configuration: - EVCC handles all PV installation details and provides aggregated forecasts - The `pv_forecast` section requires at least one entry with valid `lat` and `lon` coordinates for temperature forecasts that EOS needs for accurate optimization +- The `pv_forecast` section requires at least one entry with valid `lat` and `lon` coordinates for temperature forecasts that EOS needs for accurate optimization - The `evcc.url` must point to a reachable EVCC instance with API access enabled - Temperature forecasts are essential for EOS optimization calculations, regardless of PV forecast source @@ -549,18 +598,15 @@ pv_forecast_source: pv_forecast: - name: "Main Roof South" resource_id: "abcd-efgh-1234-5678" # Resource ID from Solcast dashboard - lat: 47.5 # Required for temperature forecasts used by EOS optimization - lon: 8.5 # Required for temperature forecasts used by EOS optimization + lat: 52.5200 # Required for temperature forecasts used by EOS optimization + lon: 13.4050 # Required for temperature forecasts used by EOS optimization power: 5000 # Still needed for system scaling - powerInverter: 5000 - inverterEfficiency: 0.95 # azimuth, tilt, horizon not used for PV forecasts - configured in Solcast dashboard - name: "Garage East" resource_id: "ijkl-mnop-9999-0000" # Different resource ID for second installation lat: 47.5 # Same location coordinates can be used for multiple installations lon: 8.5 power: 2500 - powerInverter: 3000 inverterEfficiency: 0.92 ``` @@ -574,4 +620,4 @@ pv_forecast: - **Free Solcast accounts are limited to 10 API calls per day** - **EOS Connect automatically extends update intervals to 2.5 hours when using Solcast** to stay within the 10 calls/day limit (9.6 calls/day actual usage) - Multiple PV installations will result in multiple API calls per update cycle - consider this when planning your configuration -- If you exceed rate limits, EOS Connect will use the previous forecast data until the next successful API call \ No newline at end of file +- If you exceed rate limits, EOS Connect will use the previous forecast data until the next successful API call diff --git a/src/config.py b/src/config.py index 58fd040d..55ba43cd 100644 --- a/src/config.py +++ b/src/config.py @@ -55,15 +55,16 @@ def create_default_config(self): ), "eos": CommentedMap( { - "server": "192.168.100.100", # Default EOS server address - "port": 8503, # Default port for EOS server + "source": "default", # EOS server source - eos_server, evopt, default + "server": "192.168.100.100", # EOS or EVopt server address + "port": 8503, # port for EOS server (8503) or EVopt server (7050) - default: 8503 "timeout": 180, # Default timeout for EOS optimize request } ), "price": CommentedMap( { "source": "default", - "token": "tibberBearerToken", # token for electricity price + "token": "tibberBearerToken", # token for electricity price (e.g. Tibber bearer token or Stromligning supplier/product/group) "fixed_price_adder_ct": 0.0, # Describes the fixed cost addition in ct per kWh. "relative_price_multiplier": 0.00, # Applied to (base energy price + fixed_price_adder_ct). Use a decimal (e.g., 0.05 for 5%). # 24 hours array with fixed end customer prices in ct/kWh over the day @@ -95,11 +96,14 @@ def create_default_config(self): # openmeteo, openmeteo_local, forecast_solar, akkudoktor "source": "akkudoktor", # akkudoktor, openmeteo, openmeteo_local, forecast_solar, evcc, solcast, default "api_key": "", # API key for solcast (required when source is solcast) + "source": "akkudoktor", # akkudoktor, openmeteo, openmeteo_local, forecast_solar, evcc, solcast, default + "api_key": "", # API key for solcast (required when source is solcast) } ), "pv_forecast": [ CommentedMap( { + "name": "myPvInstallation1", # Placeholder for user-defined configuration name "name": "myPvInstallation1", # Placeholder for user-defined configuration name "lat": 47.5, # Latitude for PV forecast "lon": 8.5, # Longitude for PV forecast @@ -110,6 +114,7 @@ def create_default_config(self): "inverterEfficiency": 0.9, # Inverter Efficiency for PV forecast "horizon": "10,20,10,15", # Horizon to calculate shading "resource_id": "", # Resource ID for Solcast (optional, only needed for Solcast) + "resource_id": "", # Resource ID for Solcast (optional, only needed for Solcast) } ) ], @@ -194,9 +199,14 @@ def create_default_config(self): config.yaml_set_comment_before_after_key( "eos", before="EOS server configuration" ) - config["eos"].yaml_add_eol_comment("EOS server address", "server") config["eos"].yaml_add_eol_comment( - "port for EOS server - default: 8503", "port" + "EOS server source - eos_server, evopt, default (default uses eos_server)", + "source", + ) + config["eos"].yaml_add_eol_comment("EOS or EVopt server address", "server") + config["eos"].yaml_add_eol_comment( + "port for EOS server (8503) or EVopt server (7050) - default: 8503", + "port", ) config["eos"].yaml_add_eol_comment( "timeout for EOS optimize request in seconds - default: 180", "timeout" @@ -206,11 +216,14 @@ def create_default_config(self): "price", before="Electricity price configuration" ) config["price"].yaml_add_eol_comment( - "data source for electricity price tibber, smartenergy_at," + "data source for electricity price tibber, smartenergy_at, stromligning," + " fixed_24h, default (default uses akkudoktor)", "source", ) - config["price"].yaml_add_eol_comment("Token for electricity price", "token") + config["price"].yaml_add_eol_comment( + "Token for electricity price. For Stromligning use supplierId/productId[/groupId].", + "token", + ) config["price"].yaml_add_eol_comment( "fixed cost addition in ct per kWh", "fixed_price_adder_ct" ) @@ -268,6 +281,8 @@ def create_default_config(self): "price for battery in euro/Wh - default: 0.0", "price_euro_per_wh_accu" ) config["battery"].yaml_add_eol_comment( + "enabling charging curve for controlled charging power" + + " according to the SOC (default: true)", "enabling charging curve for controlled charging power" + " according to the SOC (default: true)", "charging_curve_enabled", @@ -280,12 +295,17 @@ def create_default_config(self): config["pv_forecast_source"].yaml_add_eol_comment( "data source for solar forecast providers akkudoktor, openmeteo, openmeteo_local," + " forecast_solar, evcc, solcast, default (default uses akkudoktor)", + +" forecast_solar, evcc, solcast, default (default uses akkudoktor)", "source", ) config["pv_forecast_source"].yaml_add_eol_comment( "API key for Solcast (required only when source is 'solcast')", "api_key", ) + config["pv_forecast_source"].yaml_add_eol_comment( + "API key for Solcast (required only when source is 'solcast')", + "api_key", + ) # pv forecast configuration config.yaml_set_comment_before_after_key( "pv_forecast", @@ -331,6 +351,10 @@ def create_default_config(self): "Resource ID for Solcast API (optional, only needed when using Solcast provider)", "resource_id", ) + config["pv_forecast"][index].yaml_add_eol_comment( + "Resource ID for Solcast API (optional, only needed when using Solcast provider)", + "resource_id", + ) # inverter configuration config.yaml_set_comment_before_after_key( "inverter", before="Inverter configuration" @@ -344,14 +368,20 @@ def create_default_config(self): config["inverter"].yaml_add_eol_comment( "Address of the inverter (fronius_gen24/fronius_gen24_legacy only)", "address", + "Address of the inverter (fronius_gen24/fronius_gen24_legacy only)", + "address", ) config["inverter"].yaml_add_eol_comment( "Username for the inverter (fronius_gen24/fronius_gen24_legacy only)", "user", + "Username for the inverter (fronius_gen24/fronius_gen24_legacy only)", + "user", ) config["inverter"].yaml_add_eol_comment( "Password for the inverter (fronius_gen24/fronius_gen24_legacy only)", "password", + "Password for the inverter (fronius_gen24/fronius_gen24_legacy only)", + "password", ) config["inverter"].yaml_add_eol_comment( "Max inverter grid charge rate in W - default: 5000", "max_grid_charge_rate" diff --git a/src/constants.py b/src/constants.py new file mode 100644 index 00000000..cea4e16e --- /dev/null +++ b/src/constants.py @@ -0,0 +1,28 @@ +""" +Constants for the energy pricing application. +Includes currency symbols and minor unit mappings. +""" + +# localization maps for currencies + +CURRENCY_SYMBOL_MAP = { + "EUR": "€", + "DKK": "kr", + "NOK": "kr", + "SEK": "kr", + "USD": "$", + "GBP": "£", + "CHF": "CHF", + "CZK": "Kč", +} + +CURRENCY_MINOR_UNIT_MAP = { + "EUR": "ct", + "DKK": "øre", + "NOK": "øre", + "SEK": "öre", + "USD": "¢", + "GBP": "p", + "CHF": "Rp.", + "CZK": "haléř", +} diff --git a/src/eos_connect.py b/src/eos_connect.py index 988548ea..11771bd3 100644 --- a/src/eos_connect.py +++ b/src/eos_connect.py @@ -12,16 +12,18 @@ import pytz import requests from flask import Flask, Response, render_template_string, request, send_from_directory +from flask import Flask, Response, render_template_string, request, send_from_directory from version import __version__ from config import ConfigManager from log_handler import MemoryLogHandler +from constants import CURRENCY_SYMBOL_MAP, CURRENCY_MINOR_UNIT_MAP from interfaces.base_control import BaseControl from interfaces.load_interface import LoadInterface from interfaces.battery_interface import BatteryInterface from interfaces.inverter_fronius import FroniusWR from interfaces.inverter_fronius_v2 import FroniusWRV2 from interfaces.evcc_interface import EvccInterface -from interfaces.eos_interface import EosInterface +from interfaces.optimization_interface import OptimizationInterface from interfaces.price_interface import PriceInterface from interfaces.mqtt_interface import MqttInterface from interfaces.pv_interface import PvInterface @@ -29,6 +31,11 @@ # Check Python version early if sys.version_info < (3, 11): + sys.stderr.write( + f"ERROR: Python 3.11 or higher is required. " + f"You are running Python {sys.version_info.major}.{sys.version_info.minor}\n" + ) + sys.stderr.write("Please upgrade your Python installation.\n") sys.stderr.write( f"ERROR: Python 3.11 or higher is required. " f"You are running Python {sys.version_info.major}.{sys.version_info.minor}\n" @@ -56,20 +63,26 @@ def formatTime(self, record, datefmt=None): return record_time.strftime(datefmt or self.default_time_format) +################################################################################################## ################################################################################################## LOGLEVEL = logging.DEBUG # start before reading the config file logger = logging.getLogger(__name__) +# Basic formatter for startup logging (before config/timezone is available) +basic_formatter = logging.Formatter( + # Basic formatter for startup logging (before config/timezone is available) basic_formatter = logging.Formatter( "%(asctime)s %(levelname)s %(message)s", "%Y-%m-%d %H:%M:%S" ) streamhandler = logging.StreamHandler(sys.stdout) streamhandler.setFormatter(basic_formatter) +streamhandler.setFormatter(basic_formatter) logger.addHandler(streamhandler) logger.setLevel(LOGLEVEL) logger.info("[Main] Starting eos_connect - version: %s", __version__) + ################################################################################################### base_path = os.path.dirname(os.path.abspath(__file__)) # get param to set a specific path @@ -78,6 +91,7 @@ def formatTime(self, record, datefmt=None): else: current_dir = base_path + ################################################################################################### config_manager = ConfigManager(current_dir) time_zone = pytz.timezone(config_manager.config["time_zone"]) @@ -85,6 +99,10 @@ def formatTime(self, record, datefmt=None): LOGLEVEL = config_manager.config["log_level"].upper() logger.setLevel(LOGLEVEL) +# global time frame base +# time_frame_base = config_manager.config.get("timeframe_base", 3600) +time_frame_base = 3600 # prep for future config entry + # Now upgrade to timezone-aware formatter after config is loaded timezone_formatter = TimezoneFormatter( "%(asctime)s %(levelname)s %(message)s", "%Y-%m-%d %H:%M:%S", tz=time_zone @@ -99,19 +117,30 @@ def formatTime(self, record, datefmt=None): logger.addHandler(memory_handler) logger.debug("[Main] Memory log handler initialized successfully") +streamhandler.setFormatter(timezone_formatter) + +memory_handler = MemoryLogHandler( + max_records=10000, # All log entries (mixed levels) + max_alerts=2000, # Dedicated alert buffer (WARNING/ERROR/CRITICAL only) +) +memory_handler.setFormatter(timezone_formatter) # Use timezone formatter for web logs +logger.addHandler(memory_handler) +logger.debug("[Main] Memory log handler initialized successfully") + logger.info( "[Main] set user defined time zone to %s and loglevel to %s", config_manager.config["time_zone"], LOGLEVEL, ) # initialize eos interface -eos_interface = EosInterface( - eos_server=config_manager.config["eos"]["server"], - eos_port=config_manager.config["eos"]["port"], +eos_interface = OptimizationInterface( + config=config_manager.config["eos"], + time_frame_base=time_frame_base, timezone=time_zone, ) + # initialize base control -base_control = BaseControl(config_manager.config, time_zone) +base_control = BaseControl(config_manager.config, time_zone, time_frame_base) # initialize the inverter interface inverter_interface = None @@ -194,12 +223,12 @@ def battery_state_callback(): # callback function for mqtt interface -def mqtt_control_callback(command): +def mqtt_control_callback(mqtt_cmd): """ Handles MQTT control commands by parsing the command dictionary and updating the system's state. Args: - command (dict): Contains "duration" (str, "HH:MM"), "mode" (str/int), + mqtt_cmd (dict): Contains "duration" (str, "HH:MM"), "mode" (str/int), and "grid_charge_power" (str/int). Side Effects: @@ -207,33 +236,94 @@ def mqtt_control_callback(command): - Publishes updated control topics to MQTT. - Logs the event and triggers a control state change. """ - # Default to "02:00" if empty or None - duration_string = command.get("duration", "02:00") or "02:00" - duration_hh = duration_string.split(":")[0] - duration_mm = duration_string.split(":")[1] - duration = int(duration_hh) * 60 + int(duration_mm) - # Default to 0 if empty or None - charge_power = command.get("charge_power", 0) or 0 - charge_power = int(charge_power) / 1000 # convert to kW - # update the base control with the new charging state - base_control.set_mode_override(int(command["mode"]), duration, charge_power) - mqtt_interface.update_publish_topics( - { - "control/override_charge_power": {"value": charge_power * 1000}, - "control/override_active": { - "value": base_control.get_override_active_and_endtime()[0] - }, - "control/override_end_time": { - "value": ( - datetime.fromtimestamp( - base_control.get_override_active_and_endtime()[1], time_zone - ) - ).isoformat() - }, - } - ) - logger.info("[MAIN] MQTT Event - control command to: %s", command["mode"]) - change_control_state() + logger.info("[MAIN] MQTT Event - control command received: %s", mqtt_cmd) + + if "charge_power" in mqtt_cmd: + # Default to 0 if empty or None + charge_power = mqtt_cmd.get("charge_power", 0) or 0 + charge_power = int(charge_power) / 1000 # convert to kW + base_control.set_override_charge_rate(charge_power) + # update mqtt topics + mqtt_interface.update_publish_topics( + { + "control/override_charge_power": {"value": charge_power * 1000}, + } + ) + logger.info( + "[MAIN] MQTT Event - charge_power command to: %s", mqtt_cmd["charge_power"] + ) + + if "duration" in mqtt_cmd: + # Default to "02:00" if empty or None + duration_string = mqtt_cmd.get("duration", "02:00") or "02:00" + duration_hh = duration_string.split(":")[0] + duration_mm = duration_string.split(":")[1] + duration = int(duration_hh) * 60 + int(duration_mm) + + # update the base control with the new charging state + base_control.set_override_duration(duration) + # update mqtt topics + mqtt_interface.update_publish_topics( + { + "control/override_end_time": { + "value": ( + datetime.fromtimestamp( + base_control.get_override_active_and_endtime()[1], time_zone + ) + ).isoformat() + }, + } + ) + logger.info("[MAIN] MQTT Event - duration command to: %s", mqtt_cmd["duration"]) + + if "mode" in mqtt_cmd: + # mode + mode_value = mqtt_cmd.get("mode") + if mode_value is None: + mode_value = base_control.get_current_overall_state_number() + # update the base control with the new charging state + base_control.set_mode_override(int(mode_value)) + # update mqtt topics + mqtt_interface.update_publish_topics( + { + "control/override_charge_power": { + "value": base_control.get_override_charge_rate() * 1000 + }, + "control/override_active": { + "value": base_control.get_override_active_and_endtime()[0] + }, + "control/override_end_time": { + "value": ( + datetime.fromtimestamp( + base_control.get_override_active_and_endtime()[1], time_zone + ) + ).isoformat() + }, + } + ) + logger.info("[MAIN] MQTT Event - control command to: %s", mqtt_cmd["mode"]) + change_control_state() + # Check for battery SOC limit keys + if "soc_min" in mqtt_cmd: + soc_min = int(mqtt_cmd.get("soc_min", battery_interface.get_min_soc())) + battery_interface.set_min_soc(soc_min) + + mqtt_interface.update_publish_topics( + { + "battery/soc_min": {"value": battery_interface.get_min_soc()}, + } + ) + logger.info("[MAIN] MQTT Event - battery soc limit command: %s", mqtt_cmd) + if "soc_max" in mqtt_cmd: + soc_max = int(mqtt_cmd.get("soc_max", battery_interface.get_max_soc())) + battery_interface.set_max_soc(soc_max) + + mqtt_interface.update_publish_topics( + { + "battery/soc_max": {"value": battery_interface.get_max_soc()}, + } + ) + logger.info("[MAIN] MQTT Event - battery soc limit command: %s", mqtt_cmd) mqtt_interface = MqttInterface( @@ -258,6 +348,7 @@ def mqtt_control_callback(command): on_bat_max_changed=None, ) +price_interface = PriceInterface(config_manager.config["price"], time_zone) price_interface = PriceInterface(config_manager.config["price"], time_zone) pv_interface = PvInterface( @@ -288,15 +379,119 @@ def create_optimize_request(): dict: A dictionary containing the payload for the optimization request. """ - def get_ems_data(): + def get_dst_change_in_next_48(tz, start_dt=None): + """ + Returns: + 0 if no DST change in next 48 hours, + +N if DST fallback (extra hour) at Nth hour from now, + -N if DST spring forward (missing hour) at Nth hour from now. + """ + if start_dt is None: + start_dt = datetime.now(tz) + if start_dt.tzinfo is None: + start_dt = tz.localize(start_dt) + prev_offset = start_dt.utcoffset() + for i in range(1, 49): + check_dt = tz.normalize(start_dt + timedelta(hours=i)) + offset = check_dt.utcoffset() + if offset != prev_offset: + # DST change detected + if offset > prev_offset: + logger.debug("[DST] Spring forward detected at hour %s: -%s", i, i) + return -i # hour lost + logger.debug("[DST] Fall back detected at hour %s: +%s", i, i) + return i # hour gained + prev_offset = offset + logger.debug("[DST] No DST change detected in next 48 hours (0)") + return 0 + + # def adjust_forecast_array_for_dst(data_array, dst_change_detected): + # """ + # Adjusts the forecast array for Daylight Saving Time (DST) changes. + + # Args: + # data_array (list): The original forecast array. + # dst_change_detected (int): The DST change detected (positive for fall back, + # negative for spring forward). + # Returns: + # list: The adjusted forecast array. + # """ + # arr = list(data_array) # Make a copy so the original is not modified + # if dst_change_detected != 0: + # hour_index = abs(dst_change_detected) - 1 + + # # Validate computed index to avoid IndexError + # if hour_index < 0 or hour_index >= len(arr): + # logger.warning( + # "[DST] Computed hour index %s out of range for array length %s" + # + " - skipping DST adjustment", + # hour_index, + # len(arr), + # ) + # return arr + + # if dst_change_detected > 0: + # # Fall back - repeat hour + # arr.insert(hour_index, arr[hour_index]) # duplicate hour + # logger.debug( + # "[DST] Adjusted forecast for fall back at hour %s", + # hour_index + 1, + # ) + # else: + # # Spring forward - remove hour + # removed_value = arr.pop(hour_index) + # logger.debug( + # "[DST] Adjusted forecast for spring forward at hour %s (removed %s Wh)", + # hour_index + 1, + # removed_value, + # ) + # return arr + + def get_ems_data(dst_change_detected): + + pv_prognose_wh = pv_interface.get_current_pv_forecast() + strompreis_euro_pro_wh = price_interface.get_current_prices() + einspeiseverguetung_euro_pro_wh = price_interface.get_current_feedin_prices() + gesamtlast = load_interface.get_load_profile(EOS_TGT_DURATION) + + if config_manager.config.get("eos", {}).get("source", "eos_server") == "evopt": + now = datetime.now(time_zone) + seconds_since_midnight = now.hour * 3600 + now.minute * 60 + now.second + scale_factor = ( + time_frame_base - (seconds_since_midnight % time_frame_base) + ) / time_frame_base + + current_hour = now.hour + for ts in (pv_prognose_wh, gesamtlast): + if ts and len(ts) > current_hour: + ts[current_hour] *= scale_factor + logger.debug( + "[EOS_Request] Adjusted forecast for hour %d to %.2f Wh " + + "due to partial hour", + current_hour + 1, + ts[current_hour], + ) + + # if dst_change_detected != 0: + # pv_prognose_wh = adjust_forecast_array_for_dst( + # pv_prognose_wh, dst_change_detected + # ) + # strompreis_euro_pro_wh = adjust_forecast_array_for_dst( + # strompreis_euro_pro_wh, dst_change_detected + # ) + # einspeiseverguetung_euro_pro_wh = adjust_forecast_array_for_dst( + # einspeiseverguetung_euro_pro_wh, dst_change_detected + # ) + # gesamtlast = adjust_forecast_array_for_dst(gesamtlast, dst_change_detected) + return { - "pv_prognose_wh": pv_interface.get_current_pv_forecast(), - "strompreis_euro_pro_wh": price_interface.get_current_prices(), - "einspeiseverguetung_euro_pro_wh": price_interface.get_current_feedin_prices(), + "pv_prognose_wh": pv_prognose_wh, + "strompreis_euro_pro_wh": strompreis_euro_pro_wh, + "einspeiseverguetung_euro_pro_wh": einspeiseverguetung_euro_pro_wh, "preis_euro_pro_wh_akku": config_manager.config["battery"][ "price_euro_per_wh_accu" ], - "gesamtlast": load_interface.get_load_profile(EOS_TGT_DURATION), + "gesamtlast": gesamtlast, } def get_pv_akku_data(): @@ -312,14 +507,13 @@ def get_pv_akku_data(): "max_charge_power_w" ], "initial_soc_percentage": round(battery_interface.get_current_soc()), - "min_soc_percentage": config_manager.config["battery"][ - "min_soc_percentage" - ], - "max_soc_percentage": config_manager.config["battery"][ - "max_soc_percentage" - ], + "min_soc_percentage": battery_interface.get_min_soc(), + "max_soc_percentage": battery_interface.get_max_soc(), } - if eos_interface.get_eos_version() == ">=2025-04-09": + if ( + eos_interface.get_eos_version() == ">=2025-04-09" + or eos_interface.get_eos_version() == "0.1.0+dev" + ): akku_object = {"device_id": "battery1", **akku_object} return akku_object @@ -327,7 +521,10 @@ def get_wechselrichter_data(): wechselrichter_object = { "max_power_wh": config_manager.config["inverter"]["max_pv_charge_rate"], } - if eos_interface.get_eos_version() == ">=2025-04-09": + if ( + eos_interface.get_eos_version() == ">=2025-04-09" + or eos_interface.get_eos_version() == "0.1.0+dev" + ): wechselrichter_object = { "device_id": "inverter1", **wechselrichter_object, @@ -345,7 +542,10 @@ def get_eauto_data(): "min_soc_percentage": 5, "max_soc_percentage": 100, } - if eos_interface.get_eos_version() == ">=2025-04-09": + if ( + eos_interface.get_eos_version() == ">=2025-04-09" + or eos_interface.get_eos_version() == "0.1.0+dev" + ): eauto_object = {"device_id": "ev1", **eauto_object} return eauto_object @@ -362,17 +562,38 @@ def get_dishwasher_data(): "consumption_wh": consumption_wh, "duration_h": duration_h, } - if eos_interface.get_eos_version() == ">=2025-04-09": + if ( + eos_interface.get_eos_version() == ">=2025-04-09" + or eos_interface.get_eos_version() == "0.1.0+dev" + ): dishwasher_object = {"device_id": "additional_load_1", **dishwasher_object} + # if eos_interface.get_eos_version() == "0.1.0+dev": + # time_windows = [{"duration": "2", "start_time": "10:00"}] + # dishwasher_object = {"time_windows": time_windows, **dishwasher_object} return dishwasher_object + dst_change_detected = get_dst_change_in_next_48(time_zone) + + temperature_forecast = pv_interface.get_current_temp_forecast() + if dst_change_detected != 0: + logger.info( + "[Main] DST change detected: in %s hours there will be a shift with %s - please check" + + " https://github.com/ohAnd/EOS_connect/issues/130#issuecomment-3444749335" + + " for details.", + abs(dst_change_detected), + "1 hour plus" if dst_change_detected > 0 else "1 hour minus", + ) + # temperature_forecast = adjust_forecast_array_for_dst( + # temperature_forecast, dst_change_detected + # ) + payload = { - "ems": get_ems_data(), + "ems": get_ems_data(dst_change_detected), "pv_akku": get_pv_akku_data(), "inverter": get_wechselrichter_data(), "eauto": get_eauto_data(), "dishwasher": get_dishwasher_data(), - "temperature_forecast": pv_interface.get_current_temp_forecast(), + "temperature_forecast": temperature_forecast, "start_solution": eos_interface.get_last_start_solution(), } logger.debug( @@ -381,6 +602,14 @@ def get_dishwasher_data(): return payload +last_control_data = { + "current_soc": None, + "ac_charge_demand": None, + "dc_charge_demand": None, + "discharge_allowed": None, +} + + def setting_control_data(ac_charge_demand_rel, dc_charge_demand_rel, discharge_allowed): """ Process the optimized response from EOS and update the load interface. @@ -394,15 +623,24 @@ def setting_control_data(ac_charge_demand_rel, dc_charge_demand_rel, discharge_a current_soc = battery_interface.get_current_soc() max_soc = config_manager.config["battery"]["max_soc_percentage"] - if current_soc >= max_soc and ac_charge_demand_rel > 0: - logger.warning( - "[Main] EOS requested AC charging (%s) but battery SoC (%s%%)" - + " at/above maximum (%s%%) - overriding to 0", - ac_charge_demand_rel, - current_soc, - max_soc, - ) - ac_charge_demand_rel = 0 # Override EOS decision for safety + if ( + last_control_data["current_soc"] is not None + and last_control_data["ac_charge_demand"] is not None + ): + if ( + current_soc >= max_soc + and ac_charge_demand_rel > 0 + and last_control_data["current_soc"] != current_soc + and last_control_data["ac_charge_demand"] != ac_charge_demand_rel + ): + logger.warning( + "[Main] EOS requested AC charging (%s) but battery SoC (%s%%)" + + " at/above maximum (%s%%) - overriding to 0", + ac_charge_demand_rel, + current_soc, + max_soc, + ) + ac_charge_demand_rel = 0 # Override EOS decision for safety base_control.set_current_ac_charge_demand(ac_charge_demand_rel) base_control.set_current_dc_charge_demand(dc_charge_demand_rel) @@ -426,6 +664,11 @@ def setting_control_data(ac_charge_demand_rel, dc_charge_demand_rel, discharge_a base_control.set_current_evcc_charging_state(evcc_interface.get_charging_state()) base_control.set_current_evcc_charging_mode(evcc_interface.get_charging_mode()) + last_control_data["current_soc"] = current_soc + last_control_data["ac_charge_demand"] = ac_charge_demand_rel + last_control_data["dc_charge_demand"] = dc_charge_demand_rel + last_control_data["discharge_allowed"] = discharge_allowed + class OptimizationScheduler: """ @@ -434,14 +677,18 @@ class OptimizationScheduler: managing the lifecycle of the optimization service. Attributes: update_interval (int): The interval in seconds between optimization runs. + _update_thread_optimization_loop (threading.Thread): The background thread + running the optimization loop. _update_thread_optimization_loop (threading.Thread): The background thread running the optimization loop. _stop_event (threading.Event): An event used to signal the thread to stop. Methods: + __start_update_service_optimization_loop(): __start_update_service_optimization_loop(): shutdown(): _update_state_loop(): __run_optimization_loop(): + __run_optimization_loop(): """ def __init__(self, update_interval): @@ -472,6 +719,7 @@ def __init__(self, update_interval): "next_run": None, } self._update_thread_optimization_loop = None + self._update_thread_optimization_loop = None self._stop_event = threading.Event() self._last_avg_runtime = 120 # Initialize with a default value self.__start_update_service_optimization_loop() @@ -481,6 +729,14 @@ def __init__(self, update_interval): self._update_thread_data_loop = None self._stop_event_data_loop = threading.Event() self.__start_update_service_data_loop() + self._last_avg_runtime = 120 # Initialize with a default value + self.__start_update_service_optimization_loop() + self._update_thread_control_loop = None + self._stop_event_control_loop = threading.Event() + self.__start_update_service_control_loop() + self._update_thread_data_loop = None + self._stop_event_data_loop = threading.Event() + self.__start_update_service_data_loop() def get_last_request_response(self): """ @@ -518,21 +774,31 @@ def __set_state_next_run(self, next_run_time): """ self.current_state["next_run"] = next_run_time + def __start_update_service_optimization_loop(self): def __start_update_service_optimization_loop(self): """ Starts the background thread to periodically update the state. """ + if ( + self._update_thread_optimization_loop is None + or not self._update_thread_optimization_loop.is_alive() + ): if ( self._update_thread_optimization_loop is None or not self._update_thread_optimization_loop.is_alive() ): self._stop_event.clear() + self._update_thread_optimization_loop = threading.Thread( + target=self.__update_state_optimization_loop, daemon=True self._update_thread_optimization_loop = threading.Thread( target=self.__update_state_optimization_loop, daemon=True ) self._update_thread_optimization_loop.start() logger.info("[OPTIMIZATION] Update service Optimization Run started.") + self._update_thread_optimization_loop.start() + logger.info("[OPTIMIZATION] Update service Optimization Run started.") + def __update_state_optimization_loop(self): def __update_state_optimization_loop(self): """ The loop that runs in the background thread to update the state. @@ -570,19 +836,58 @@ def __update_state_optimization_loop(self): seconds, ) + self.__run_optimization_loop() + + # Calculate actual sleep time based on smart scheduling + loop_now = datetime.now(time_zone) + next_eval = eos_interface.calculate_next_run_time( + loop_now, + getattr(self, "_last_avg_runtime", 120), # Use last known runtime + self.update_interval, + ) + actual_sleep_interval = max(10, (next_eval - loop_now).total_seconds()) + self.__set_state_next_run(next_eval.astimezone(time_zone).isoformat()) + mqtt_interface.update_publish_topics( + { + "optimization/last_run": { + "value": self.get_current_state()["last_response_timestamp"] + }, + "optimization/next_run": { + "value": self.get_current_state()["next_run"] + }, + } + ) + minutes, seconds = divmod(actual_sleep_interval, 60) + logger.info( + "[Main] Next optimization at %s (based on average runtime of %.0f seconds)." + + " Sleeping for %d min %.0f seconds\n", + next_eval.strftime("%H:%M:%S"), + getattr(self, "_last_avg_runtime", 120), + minutes, + seconds, + ) + except (requests.exceptions.RequestException, ValueError, KeyError) as e: logger.error("[OPTIMIZATION] Error while updating state: %s", e) actual_sleep_interval = self.update_interval # Fallback on error + # Use the calculated sleep interval instead of fixed interval + while actual_sleep_interval > 0: + actual_sleep_interval = self.update_interval # Fallback on error + # Use the calculated sleep interval instead of fixed interval while actual_sleep_interval > 0: if self._stop_event.is_set(): return # Exit immediately if stop event is set time.sleep(min(1, actual_sleep_interval)) # Sleep in 1-second chunks actual_sleep_interval -= 1 + time.sleep(min(1, actual_sleep_interval)) # Sleep in 1-second chunks + actual_sleep_interval -= 1 # self.__start_update_service_optimization_loop() + # self.__start_update_service_optimization_loop() + def __run_optimization_loop(self): def __run_optimization_loop(self): """ Executes the optimization process by creating an optimization request, @@ -611,6 +916,10 @@ def __run_optimization_loop(self): # EOS_TGT_DURATION, # datetime.now(time_zone).replace(hour=0, minute=0, second=0, microsecond=0), # ) + # price_interface.update_prices( + # EOS_TGT_DURATION, + # datetime.now(time_zone).replace(hour=0, minute=0, second=0, microsecond=0), + # ) # create optimize request json_optimize_input = create_optimize_request() self.__set_state_request() @@ -623,11 +932,13 @@ def __run_optimization_loop(self): mqtt_interface.update_publish_topics( {"optimization/state": {"value": self.get_current_state()["request_state"]}} ) - optimized_response, avg_runtime = eos_interface.eos_set_optimize_request( + optimized_response, avg_runtime = eos_interface.optimize( json_optimize_input, config_manager.config["eos"]["timeout"] ) # Store the runtime for use in sleep calculation self._last_avg_runtime = avg_runtime + # Store the runtime for use in sleep calculation + self._last_avg_runtime = avg_runtime json_optimize_input["timestamp"] = datetime.now(time_zone).isoformat() self.last_request_response["request"] = json.dumps( @@ -739,7 +1050,8 @@ def __run_control_loop(self): if error is not True: # logger.debug( - # "[Main] Optimization fast control loop - current state: %s (Num: %s) -> ac_charge_demand: %s, dc_charge_demand: %s, discharge_allowed: %s", + # "[Main] Optimization fast control loop - current state: %s (Num: %s) "+ + # "-> ac_charge_demand: %s, dc_charge_demand: %s, discharge_allowed: %s", # base_control.get_current_overall_state(), # base_control.get_current_overall_state_number(), # ac_charge_demand, @@ -761,6 +1073,7 @@ def __run_control_loop(self): # base_control.get_current_overall_state_number(), # ) + def __start_update_service_data_loop(self): def __start_update_service_data_loop(self): """ Starts the background thread to periodically update the state. @@ -768,34 +1081,48 @@ def __start_update_service_data_loop(self): if ( self._update_thread_data_loop is None or not self._update_thread_data_loop.is_alive() + self._update_thread_data_loop is None + or not self._update_thread_data_loop.is_alive() ): self._stop_event_data_loop.clear() + self._update_thread_data_loop = threading.Thread( + target=self.__update_state_loop_data_loop, daemon=True + self._stop_event_data_loop.clear() self._update_thread_data_loop = threading.Thread( target=self.__update_state_loop_data_loop, daemon=True ) self._update_thread_data_loop.start() logger.info("[OPTIMIZATION] Update service Data started.") + self._update_thread_data_loop.start() + logger.info("[OPTIMIZATION] Update service Data started.") + def __update_state_loop_data_loop(self): def __update_state_loop_data_loop(self): """ The loop that runs in the background thread to update the state. """ + while not self._stop_event_data_loop.is_set(): while not self._stop_event_data_loop.is_set(): try: self.__run_data_loop() + self.__run_data_loop() except (requests.exceptions.RequestException, ValueError, KeyError) as e: logger.error( "[OPTIMIZATION] Error while running data control loop: %s", e + "[OPTIMIZATION] Error while running data control loop: %s", e ) # Break the sleep interval into smaller chunks to allow immediate shutdown sleep_interval = 15 while sleep_interval > 0: + if self._stop_event_data_loop.is_set(): if self._stop_event_data_loop.is_set(): return # Exit immediately if stop event is set time.sleep(min(1, sleep_interval)) # Sleep in 1-second chunks sleep_interval -= 1 self.__start_update_service_data_loop() + self.__start_update_service_data_loop() + def __run_data_loop(self): def __run_data_loop(self): if inverter_type in ["fronius_gen24", "fronius_gen24_legacy"]: inverter_interface.fetch_inverter_data() @@ -842,6 +1169,10 @@ def shutdown(self): """ Stops the background thread and shuts down the update service. """ + if ( + self._update_thread_optimization_loop + and self._update_thread_optimization_loop.is_alive() + ): if ( self._update_thread_optimization_loop and self._update_thread_optimization_loop.is_alive() @@ -860,6 +1191,19 @@ def shutdown(self): self._stop_event_data_loop.set() self._update_thread_data_loop.join() logger.info("[OPTIMIZATION] Update service Data Loop stopped.") + self._update_thread_optimization_loop.join() + logger.info("[OPTIMIZATION] Update service Optimization Loop stopped.") + if ( + self._update_thread_control_loop + and self._update_thread_control_loop.is_alive() + ): + self._stop_event_control_loop.set() + self._update_thread_control_loop.join() + logger.info("[OPTIMIZATION] Update service Control Loop stopped.") + if self._update_thread_data_loop and self._update_thread_data_loop.is_alive(): + self._stop_event_data_loop.set() + self._update_thread_data_loop.join() + logger.info("[OPTIMIZATION] Update service Data Loop stopped.") optimization_scheduler = OptimizationScheduler( @@ -929,6 +1273,8 @@ def change_control_state(): "battery/dyn_max_charge_power": { "value": battery_interface.get_max_charge_power() }, + "battery/soc_min": {"value": battery_interface.get_min_soc()}, + "battery/soc_max": {"value": battery_interface.get_max_soc()}, "status": {"value": "online"}, } ) @@ -936,7 +1282,7 @@ def change_control_state(): # get the current ac/dc charge demand and for setting to inverter according # to the max dynamic charge power of the battery based on SOC tgt_ac_charge_power = min( - base_control.get_current_ac_charge_demand(), + base_control.get_needed_ac_charge_power(), round(battery_interface.get_max_charge_power()), ) tgt_dc_charge_power = min( @@ -949,6 +1295,7 @@ def change_control_state(): ) # Check if the overall state of the inverter was changed recently + if base_control.was_overall_state_changed_recently(): if base_control.was_overall_state_changed_recently(): logger.debug("[Main] Overall state changed recently") # MODE_CHARGE_FROM_GRID @@ -1015,11 +1362,28 @@ def change_control_state(): "[Main] Inverter mode set to %s (_____+-+-+_____)", current_overall_state_text, ) + # MODE_CHARGE_FROM_GRID_EVCC_FAST + elif current_overall_state == 6: + if inverter_fronius_en: + inverter_interface.set_mode_force_charge(tgt_ac_charge_power) + elif inverter_evcc_en: + evcc_interface.set_external_battery_mode("force_charge") + logger.info( + "[Main] Inverter mode set to %s with %s W (_____|---|_____)", + current_overall_state_text, + tgt_ac_charge_power, + ) elif current_overall_state < 0: logger.warning("[Main] Inverter mode not initialized yet") return True # Log the current state if no recent changes were made + if datetime.now().minute % 5 == 0 and datetime.now().second == 0: + logger.info( + "[Main] Overall state not changed recently" + + " - remaining in current state: %s (_____OOOOO_____)", + current_overall_state_text, + ) if datetime.now().minute % 5 == 0 and datetime.now().second == 0: logger.info( "[Main] Overall state not changed recently" @@ -1054,6 +1418,22 @@ def main_page_legacy(): # new web site support +# legacy web site support +@app.route("/index_legacy.html", methods=["GET"]) +def main_page_legacy(): + """ + Renders the main page of the web application. + + This function reads the content of the 'index.html' file located in the 'web' directory + and returns it as a rendered template string. + """ + with open(base_path + "/web/index_legacy.html", "r", encoding="utf-8") as html_file: + return render_template_string(html_file.read()) + + +# new web site support + + @app.route("/", methods=["GET"]) def main_page(): """ @@ -1066,6 +1446,8 @@ def main_page(): return render_template_string(html_file.read()) +@app.route("/js/") +def serve_js_files(filename): @app.route("/js/") def serve_js_files(filename): """ @@ -1096,6 +1478,38 @@ def serve_js_files(filename): return "Server Error", 500 +# Also add CSS file serving for completeness +@app.route("/css/") +def serve_css_files(filename): + """ + Dynamically serve CSS files from the web directory. + Dynamically serve JavaScript files from the js directory. + This allows adding new JS modules without modifying the server code. + """ + try: + js_directory = os.path.join(os.path.dirname(__file__), "web", "js") + + # Security check: only allow .js files + if not filename.endswith(".js"): + logger.warning("[Web] Blocked attempt to serve non-JS file: %s", filename) + return "Not Found", 404 + + # Check if file exists + file_path = os.path.join(js_directory, filename) + if not os.path.exists(file_path): + logger.warning("[Web] JavaScript file not found: %s", filename) + return "Not Found", 404 + + # logger.debug("[Web] Serving JavaScript file: %s", filename) + return send_from_directory( + js_directory, filename, mimetype="application/javascript" + ) + + except (OSError, IOError, ValueError) as e: + logger.error("[Web] Error serving JavaScript file %s: %s", filename, e) + return "Server Error", 500 + + # Also add CSS file serving for completeness @app.route("/css/") def serve_css_files(filename): @@ -1122,6 +1536,26 @@ def serve_css_files(filename): except (OSError, IOError, ValueError) as e: logger.error("[Web] Error serving CSS file %s: %s", filename, e) return "Server Error", 500 + try: + web_directory = os.path.join(os.path.dirname(__file__), "web", "css") + + # Security check: only allow .css files + if not filename.endswith(".css"): + logger.warning("[Web] Blocked attempt to serve non-CSS file: %s", filename) + return "Not Found", 404 + + # Check if file exists + file_path = os.path.join(web_directory, filename) + if not os.path.exists(file_path): + logger.warning("[Web] CSS file not found: %s", filename) + return "Not Found", 404 + + # logger.debug("[Web] Serving CSS file: %s", filename) + return send_from_directory(web_directory, filename, mimetype="text/css") + + except (OSError, IOError, ValueError) as e: + logger.error("[Web] Error serving CSS file %s: %s", filename, e) + return "Server Error", 500 @app.route("/json/optimize_request.json", methods=["GET"]) @@ -1146,11 +1580,15 @@ def get_optimize_response(): ) + @app.route("/json/optimize_request.test.json", methods=["GET"]) def get_optimize_request_test(): """ Retrieves the last optimization request and returns it as a JSON response. """ + with open( + base_path + "/json/optimize_request.test.json", "r", encoding="utf-8" + ) as file: with open( base_path + "/json/optimize_request.test.json", "r", encoding="utf-8" ) as file: @@ -1160,11 +1598,15 @@ def get_optimize_request_test(): ) + @app.route("/json/optimize_response.test.json", methods=["GET"]) def get_optimize_response_test(): """ Retrieves the last optimization response and returns it as a JSON response. """ + with open( + base_path + "/json/optimize_response.test.json", "r", encoding="utf-8" + ) as file: with open( base_path + "/json/optimize_response.test.json", "r", encoding="utf-8" ) as file: @@ -1187,6 +1629,10 @@ def get_controls(): current_inverter_mode = base_control.get_current_overall_state() current_inverter_mode_num = base_control.get_current_overall_state_number() + currency = price_interface.get_price_currency() + currency_symbol = CURRENCY_SYMBOL_MAP.get(currency, currency) + currency_minor_unit = CURRENCY_MINOR_UNIT_MAP.get(currency, f"{currency}") + response_data = { "current_states": { "current_ac_charge_demand": current_ac_charge_demand, @@ -1218,16 +1664,80 @@ def get_controls(): else None ) }, + "localization": { + "currency": currency, + "currency_symbol": currency_symbol, + "currency_minor_unit": currency_minor_unit, + }, "state": optimization_scheduler.get_current_state(), + "used_optimization_source": config_manager.config.get("eos", {}).get( + "source", "eos_server" + ), "eos_connect_version": __version__, "timestamp": datetime.now(time_zone).isoformat(), - "api_version": "0.0.1", + "api_version": "0.0.2", } return Response( json.dumps(response_data, indent=4), content_type="application/json" ) +@app.route("/json/test/") +def serve_test_json_files(filename): + """ + Dynamically serve test JSON files from the json directory. + This allows adding new test JSON files without modifying the server code. + Supports all test files like current_controls.test.json, optimize_request.test.json, etc. + """ + try: + # Test files are in the json/test/ subdirectory + json_test_directory = os.path.join(os.path.dirname(__file__), "json", "test") + + # Security check: only allow .json files + if not filename.endswith(".json"): + logger.warning("[Web] Blocked attempt to serve non-JSON file: %s", filename) + return Response( + '{"error": "Invalid file type"}', + status=400, + content_type="application/json", + ) + + # Additional security: only allow files with .test.json ending + # (all test files must follow this naming convention) + if not filename.endswith(".test.json"): + logger.warning( + "[Web] Blocked attempt to serve non-test JSON file: %s", filename + ) + return Response( + '{"error": "Access denied - not a test file"}', + status=403, + content_type="application/json", + ) + + # Check if file exists in test directory + file_path = os.path.join(json_test_directory, filename) + if not os.path.exists(file_path): + logger.warning("[Web] Test JSON file not found: %s", filename) + logger.debug("[Web] Looked in directory: %s", json_test_directory) + return Response( + '{"error": "Test file not found"}', + status=404, + content_type="application/json", + ) + + # logger.info("[Web] Serving test JSON file: %s from %s", filename, json_test_directory) + return send_from_directory( + json_test_directory, filename, mimetype="application/json" + ) + + except (OSError, IOError, ValueError) as e: + logger.error("[Web] Error serving test JSON file %s: %s", filename, e) + return Response( + '{"error": "Server error"}', status=500, content_type="application/json" + ) + ) + + @app.route("/json/test/") def serve_test_json_files(filename): """ @@ -1352,7 +1862,9 @@ def handle_mode_override(): ) # Apply the override - base_control.set_mode_override(mode, duration, grid_charge_power) + base_control.set_override_charge_rate(grid_charge_power) + base_control.set_override_duration(duration) + base_control.set_mode_override(mode) change_control_state() if mode == -1: logger.info("[Main] Mode override deactivated") @@ -1538,6 +2050,154 @@ def get_log_stats(): ) +@app.route("/logs", methods=["GET"]) +def get_logs(): + """ + Retrieve application logs with optional filtering. + + Query parameters: + - level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + - limit: Maximum number of records to return (default: 100) + - since: ISO timestamp to get logs since that time + """ + try: + level_filter = request.args.get("level") + limit = int(request.args.get("limit", 100)) + since = request.args.get("since") + + logs = memory_handler.get_logs( + level_filter=level_filter, limit=limit, since=since + ) + + response_data = { + "logs": logs, + "total_count": len(logs), + "timestamp": datetime.now(time_zone).isoformat(), + "filters_applied": {"level": level_filter, "limit": limit, "since": since}, + } + + return Response( + json.dumps(response_data, indent=2), content_type="application/json" + ) + + except (ValueError, TypeError, KeyError) as e: + logger.error("[Web] Error retrieving logs: %s", e) + return Response( + json.dumps({"error": "Failed to retrieve logs"}), + status=500, + content_type="application/json", + ) + + +@app.route("/logs/alerts", methods=["GET"]) +def get_alerts(): + """ + Retrieve warning and error logs for alert system. + """ + try: + alerts = memory_handler.get_alerts() + + # Group alerts by level for easier processing + grouped_alerts = { + "WARNING": [a for a in alerts if a["level"] == "WARNING"], + "ERROR": [a for a in alerts if a["level"] == "ERROR"], + "CRITICAL": [a for a in alerts if a["level"] == "CRITICAL"], + } + + response_data = { + "alerts": alerts, + "grouped_alerts": grouped_alerts, + "alert_counts": { + level: len(items) for level, items in grouped_alerts.items() + }, + "timestamp": datetime.now(time_zone).isoformat(), + } + + return Response( + json.dumps(response_data, indent=2), content_type="application/json" + ) + + except (ValueError, TypeError, KeyError) as e: + logger.error("[Web] Error retrieving alerts: %s", e) + return Response( + json.dumps({"error": "Failed to retrieve alerts"}), + status=500, + content_type="application/json", + ) + + +@app.route("/logs/clear", methods=["POST"]) +def clear_logs(): + """ + Clear all stored logs from memory (file logs remain intact). + """ + try: + memory_handler.clear_logs() + logger.info("[Web] Memory logs cleared via web API") + + return Response( + json.dumps({"status": "success", "message": "Logs cleared"}), + content_type="application/json", + ) + + except (RuntimeError, ValueError, TypeError, KeyError) as e: + logger.error("[Web] Error clearing logs: %s", e) + return Response( + json.dumps({"error": "Failed to clear logs"}), + status=500, + content_type="application/json", + ) + + +@app.route("/logs/alerts/clear", methods=["POST"]) +def clear_alerts_only(): + """ + Clear only alert logs from memory, keeping regular logs intact. + """ + try: + memory_handler.clear_alerts_only() + logger.info("[Web] Alert logs cleared via web API") + + return Response( + json.dumps({"status": "success", "message": "Alert logs cleared"}), + content_type="application/json", + ) + + except (RuntimeError, ValueError, TypeError, KeyError) as e: + logger.error("[Web] Error clearing alert logs: %s", e) + return Response( + json.dumps({"error": "Failed to clear alert logs"}), + status=500, + content_type="application/json", + ) + + +@app.route("/logs/stats", methods=["GET"]) +def get_log_stats(): + """ + Get buffer usage statistics. + """ + try: + stats = memory_handler.get_buffer_stats() + + response_data = { + "buffer_stats": stats, + "timestamp": datetime.now(time_zone).isoformat(), + } + + return Response( + json.dumps(response_data, indent=2), content_type="application/json" + ) + + except (ValueError, TypeError, KeyError) as e: + logger.error("[Web] Error retrieving buffer stats: %s", e) + return Response( + json.dumps({"error": "Failed to retrieve buffer stats"}), + status=500, + content_type="application/json", + ) + + if __name__ == "__main__": http_server = None try: @@ -1569,6 +2229,7 @@ def get_log_stats(): logger.error("[Main] EOS Connect cannot start without its web interface.") sys.exit(1) + except (OSError, ImportError) as e: except (OSError, ImportError) as e: # Only handle truly unexpected errors (not port-related) logger.error("[Main] Unexpected error: %s", str(e)) @@ -1592,11 +2253,13 @@ def get_log_stats(): inverter_interface.shutdown() pv_interface.shutdown() price_interface.shutdown() + price_interface.shutdown() mqtt_interface.shutdown() evcc_interface.shutdown() battery_interface.shutdown() logger.info("[Main] Server stopped gracefully") finally: + logging.shutdown() # This will call close() on all handlers logging.shutdown() # This will call close() on all handlers logger.info("[Main] Cleanup complete. Goodbye!") sys.exit(0) diff --git a/src/interfaces/base_control.py b/src/interfaces/base_control.py index 3d7dc630..285cca2c 100644 --- a/src/interfaces/base_control.py +++ b/src/interfaces/base_control.py @@ -11,6 +11,7 @@ logger = logging.getLogger("__main__") logger.info("[BASE-CTRL] loading module ") +logger.info("[BASE-CTRL] loading module ") MODE_CHARGE_FROM_GRID = 0 MODE_AVOID_DISCHARGE = 1 @@ -18,6 +19,7 @@ MODE_AVOID_DISCHARGE_EVCC_FAST = 3 MODE_DISCHARGE_ALLOWED_EVCC_PV = 4 MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV = 5 +MODE_CHARGE_FROM_GRID_EVCC_FAST = 6 state_mapping = { -2: "BACK TO AUTO", @@ -28,6 +30,7 @@ 3: "MODE AVOID DISCHARGE EVCC FAST", 4: "MODE DISCHARGE ALLOWED EVCC PV", 5: "MODE DISCHARGE ALLOWED EVCC MIN+PV", + 6: "MODE CHARGE FROM GRID EVCC FAST", } @@ -39,9 +42,10 @@ class BaseControl: MODE_CHARGE_FROM_GRID, MODE_AVOID_DISCHARGE, or MODE_DISCHARGE_ALLOWED. """ - def __init__(self, config, timezone): + def __init__(self, config, timezone, time_frame_base): self.current_ac_charge_demand = 0 self.last_ac_charge_demand = 0 + self.last_ac_charge_power = 0 self.current_ac_charge_demand_no_override = 0 self.current_dc_charge_demand = 0 self.last_dc_charge_demand = 0 @@ -51,10 +55,15 @@ def __init__(self, config, timezone): self.current_discharge_allowed = -1 self.current_evcc_charging_state = False self.current_evcc_charging_mode = False + # 1 hour = 3600 seconds / 900 for 15 minutes + self.time_frame_base = time_frame_base # startup with None to force a writing to the inverter self.current_overall_state = -1 self.override_active = False + self.override_active_since = 0 self.override_end_time = 0 + self.override_charge_rate = 0 + self.override_duration = 0 self.current_battery_soc = 0 self.time_zone = timezone self.config = config @@ -70,6 +79,7 @@ def get_state_mapping(self, num_mode): """ return state_mapping.get(num_mode, "unknown state") + def was_overall_state_changed_recently(self, time_window_seconds=1): def was_overall_state_changed_recently(self, time_window_seconds=1): """ Checks if the overall state was changed within the last `time_window_seconds`. @@ -101,6 +111,7 @@ def get_current_bat_charge_max(self): """ logger.debug( "[BASE-CTRL] get current battery charge max %s", self.current_bat_charge_max + "[BASE-CTRL] get current battery charge max %s", self.current_bat_charge_max ) return self.current_bat_charge_max @@ -147,6 +158,18 @@ def get_override_active_and_endtime(self): """ return self.override_active, int(self.override_end_time) + def get_override_charge_rate(self): + """ + Returns the override charge rate. + """ + return self.override_charge_rate + + def get_override_duration(self): + """ + Returns the override duration. + """ + return self.override_duration + def set_current_ac_charge_demand(self, value_relative): """ Sets the current AC charge demand. @@ -156,28 +179,26 @@ def set_current_ac_charge_demand(self, value_relative): value_relative * self.config["battery"]["max_charge_power_w"] ) if current_charge_demand == self.current_ac_charge_demand: - # logger.debug( - # "[BASE-CTRL] NO CHANGE AC charge demand for current hour %s:00 "+ - # "unchanged -> %s Wh -" - # + " based on max charge power %s W", - # current_hour, - # self.current_ac_charge_demand, - # self.config["battery"]["max_charge_power_w"], - # ) + # No change, so do not log return # store the current charge demand without override self.current_ac_charge_demand_no_override = current_charge_demand if not self.override_active: self.current_ac_charge_demand = current_charge_demand logger.debug( + "[BASE-CTRL] set AC charge demand for current hour %s:00 -> %s Wh -" "[BASE-CTRL] set AC charge demand for current hour %s:00 -> %s Wh -" + " based on max charge power %s W", current_hour, self.current_ac_charge_demand, self.config["battery"]["max_charge_power_w"], ) - else: + elif self.override_active_since > time.time() - 2: + # self.current_ac_charge_demand = ( + # current_charge_demand # Ensure override updates demand + # ) logger.debug( + "[BASE-CTRL] OVERRIDE AC charge demand for current hour %s:00 -> %s Wh -" "[BASE-CTRL] OVERRIDE AC charge demand for current hour %s:00 -> %s Wh -" + " based on max charge power %s W", current_hour, @@ -194,6 +215,16 @@ def set_current_dc_charge_demand(self, value_relative): current_charge_demand = ( value_relative * self.config["battery"]["max_charge_power_w"] ) + if current_charge_demand == self.current_dc_charge_demand: + # logger.debug( + # "[BASE-CTRL] NO CHANGE DC charge demand for current hour %s:00 "+ + # "unchanged -> %s Wh -" + # + " based on max charge power %s W", + # current_hour, + # self.current_dc_charge_demand, + # self.config["battery"]["max_charge_power_w"], + # ) + return if current_charge_demand == self.current_dc_charge_demand: # logger.debug( # "[BASE-CTRL] NO CHANGE DC charge demand for current hour %s:00 "+ @@ -209,6 +240,7 @@ def set_current_dc_charge_demand(self, value_relative): if not self.override_active: self.current_dc_charge_demand = current_charge_demand logger.debug( + "[BASE-CTRL] set DC charge demand for current hour %s:00 -> %s Wh -" "[BASE-CTRL] set DC charge demand for current hour %s:00 -> %s Wh -" + " based on max charge power %s W", current_hour, @@ -217,6 +249,7 @@ def set_current_dc_charge_demand(self, value_relative): ) else: logger.debug( + "[BASE-CTRL] OVERRIDE DC charge demand for current hour %s:00 -> %s Wh -" "[BASE-CTRL] OVERRIDE DC charge demand for current hour %s:00 -> %s Wh -" + " based on max charge power %s W", current_hour, @@ -229,6 +262,12 @@ def set_current_bat_charge_max(self, value_max): """ Sets the current maximum battery charge power. """ + if value_max == self.current_bat_charge_max: + # logger.debug( + # "[BASE-CTRL] NO CHANGE Battery charge max unchanged -> %s W", + # self.current_bat_charge_max, + # ) + return if value_max == self.current_bat_charge_max: # logger.debug( # "[BASE-CTRL] NO CHANGE Battery charge max unchanged -> %s W", @@ -240,6 +279,8 @@ def set_current_bat_charge_max(self, value_max): logger.debug( "[BASE-CTRL] set current battery charge max to %s", self.current_bat_charge_max, + "[BASE-CTRL] set current battery charge max to %s", + self.current_bat_charge_max, ) self.__set_current_overall_state() @@ -248,6 +289,13 @@ def set_current_discharge_allowed(self, value): Sets the current discharge demand. """ current_hour = datetime.now(self.time_zone).hour + if value == self.current_discharge_allowed: + # logger.debug( + # "[BASE-CTRL] NO CHANGE Discharge allowed for current hour %s:00 unchanged -> %s", + # current_hour, + # self.current_discharge_allowed, + # ) + return if value == self.current_discharge_allowed: # logger.debug( # "[BASE-CTRL] NO CHANGE Discharge allowed for current hour %s:00 unchanged -> %s", @@ -257,6 +305,7 @@ def set_current_discharge_allowed(self, value): return self.current_discharge_allowed = value logger.debug( + "[BASE-CTRL] set Discharge allowed for current hour %s:00 %s", "[BASE-CTRL] set Discharge allowed for current hour %s:00 %s", current_hour, self.current_discharge_allowed, @@ -269,6 +318,7 @@ def set_current_evcc_charging_state(self, value): """ self.current_evcc_charging_state = value # logger.debug("[BASE-CTRL] set current EVCC charging state to %s", value) + # logger.debug("[BASE-CTRL] set current EVCC charging state to %s", value) self.__set_current_overall_state() def set_current_evcc_charging_mode(self, value): @@ -277,8 +327,46 @@ def set_current_evcc_charging_mode(self, value): """ self.current_evcc_charging_mode = value # logger.debug("[BASE-CTRL] set current EVCC charging mode to %s", value) + # logger.debug("[BASE-CTRL] set current EVCC charging mode to %s", value) self.__set_current_overall_state() + def get_needed_ac_charge_power(self): + """ + Calculates the required AC charge power to deliver the target energy + within the remaining time frame. + """ + current_time = datetime.now(self.time_zone) + # Calculate the seconds elapsed in the current time frame with time_frame_base + seconds_elapsed = ( + current_time.hour * 3600 + current_time.minute * 60 + current_time.second + ) % self.time_frame_base + + # Calculate the remaining seconds in the current time frame + seconds_to_end_of_current_time_frame = self.time_frame_base - seconds_elapsed + + # Calculate the required AC charge power to deliver the target energy within + # the remaining time frame. + # tgt_ac_charge_demand is the total energy (in Wh) needed in the current time frame. + # seconds_to_end_of_current_time_frame is the remaining seconds in the time frame. + # The needed power (in W) is calculated as energy divided by time (in hours). + if seconds_to_end_of_current_time_frame > 0: + needed_ac_charge_power = round( + self.current_ac_charge_demand + / (seconds_to_end_of_current_time_frame / 3600), + 0, + ) + # logger.debug( + # "[BASE-CTRL] needed AC charge power to reach target %s W in current time frame", + # needed_ac_charge_power, + # ) + else: + # No time left in the current time frame - use last value + needed_ac_charge_power = self.last_ac_charge_power + + self.last_ac_charge_power = needed_ac_charge_power + + return needed_ac_charge_power + def __set_current_overall_state(self): """ Sets the current overall state and logs the timestamp if it changes. @@ -286,10 +374,13 @@ def __set_current_overall_state(self): if self.override_active: # check if the override end time is reached if time.time() > self.override_end_time: + logger.info("[BASE-CTRL] OVERRIDE end time reached, clearing override") logger.info("[BASE-CTRL] OVERRIDE end time reached, clearing override") self.clear_mode_override() return return + + # Determine base state if self.current_ac_charge_demand > 0: new_state = MODE_CHARGE_FROM_GRID elif self.current_discharge_allowed > 0: @@ -298,97 +389,80 @@ def __set_current_overall_state(self): new_state = MODE_AVOID_DISCHARGE else: new_state = -1 - # check if the grid charge demand has changed - grid_charge_value_changed = ( - self.current_ac_charge_demand != self.last_ac_charge_demand - ) - dc_charge_value_changed = ( - self.current_dc_charge_demand != self.last_dc_charge_demand - ) - bat_charge_max_value_changed = ( - self.current_bat_charge_max != self.last_bat_charge_max - ) - # override overall state if EVCC charging state is active and - # in mode fast charge and discharge is allowed - if ( - # new_state == MODE_DISCHARGE_ALLOWED - # and - self.current_evcc_charging_state - and self.current_evcc_charging_mode in ("now", "pv+now", "minpv+now") - ): - new_state = MODE_AVOID_DISCHARGE_EVCC_FAST - logger.info( - "[BASE-CTRL] EVCC charging state is active," - + " setting overall state to MODE_AVOID_DISCHARGE_EVCC_FAST" - ) - - # override overall state if EVCC charging state is active and - # in mode pv charge and discharge is allowed - if ( - # new_state == MODE_DISCHARGE_ALLOWED - # and - self.current_evcc_charging_state - and self.current_evcc_charging_mode == "pv" - ): - new_state = MODE_DISCHARGE_ALLOWED_EVCC_PV - logger.info( - "[BASE-CTRL] EVCC charging state is active," - + " setting overall state to MODE_DISCHARGE_ALLOWED_EVCC_PV" - ) - - # override overall state if EVCC charging state is active and - # in mode pv charge and discharge is allowed - if ( - # new_state == MODE_DISCHARGE_ALLOWED - # and - self.current_evcc_charging_state - and self.current_evcc_charging_mode == "minpv" - ): - new_state = MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV - logger.info( - "[BASE-CTRL] EVCC charging state is active," - + " setting overall state to MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV" - ) + # EVCC override mapping + evcc_override = { + "now": MODE_AVOID_DISCHARGE_EVCC_FAST, + "pv+now": MODE_AVOID_DISCHARGE_EVCC_FAST, + "minpv+now": MODE_AVOID_DISCHARGE_EVCC_FAST, + "pv+plan": MODE_AVOID_DISCHARGE_EVCC_FAST, + "minpv+plan": MODE_AVOID_DISCHARGE_EVCC_FAST, + "pv": MODE_DISCHARGE_ALLOWED_EVCC_PV, + "minpv": MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV, + } + + if self.current_evcc_charging_state: + mode = self.current_evcc_charging_mode + if mode in evcc_override: + # Fast charge overrides grid charge + if new_state == MODE_CHARGE_FROM_GRID and mode in ( + "now", + "pv+now", + "minpv+now", + "pv+plan", + "minpv+plan", + ): + new_state = MODE_CHARGE_FROM_GRID_EVCC_FAST + if self.current_overall_state != new_state: + logger.info( + "[BASE-CTRL] EVCC charging state is active, setting overall state to MODE_CHARGE_FROM_GRID_EVCC_FAST" + ) + else: + new_state = evcc_override[mode] + if self.current_overall_state != new_state: + logger.info( + "[BASE-CTRL] EVCC charging state is active, setting overall state to %s", + state_mapping.get(new_state, "unknown state"), + ) + + # Check for changes + changes = [ + ( + "AC charge demand", + self.current_ac_charge_demand, + self.last_ac_charge_demand, + ), + ( + "DC charge demand", + self.current_dc_charge_demand, + self.last_dc_charge_demand, + ), + ( + "Battery charge max", + self.current_bat_charge_max, + self.last_bat_charge_max, + ), + ] + value_changed = any(curr != last for _, curr, last in changes) - if ( - new_state != self.current_overall_state - or grid_charge_value_changed - or dc_charge_value_changed - or bat_charge_max_value_changed - ): + if new_state != self.current_overall_state or value_changed: self._state_change_timestamps.append(time.time()) - # Limit the size of the state change timestamps to avoid memory overrun - max_timestamps = 1000 # Adjust this value as needed - if len(self._state_change_timestamps) > max_timestamps: + if len(self._state_change_timestamps) > 1000: self._state_change_timestamps.pop(0) - if grid_charge_value_changed: - logger.info( - "[BASE-CTRL] AC charge demand changed to %s W", - self.current_ac_charge_demand, - ) - elif dc_charge_value_changed: - logger.info( - "[BASE-CTRL] DC charge demand changed to %s W", - self.current_dc_charge_demand, - ) - elif bat_charge_max_value_changed: - logger.info( - "[BASE-CTRL] Battery charge max changed to %s W", - self.current_bat_charge_max, - ) - else: + for name, curr, last in changes: + if curr != last: + logger.info("[BASE-CTRL] %s changed to %s W", name, curr) + if not value_changed: logger.debug( + "[BASE-CTRL] overall state changed to %s", "[BASE-CTRL] overall state changed to %s", state_mapping.get(new_state, "unknown state"), ) - # store the last AC charge demand for comparison + + # Update last values and state self.last_ac_charge_demand = self.current_ac_charge_demand - # store the last DC charge demand for comparison self.last_dc_charge_demand = self.current_dc_charge_demand - # store the last battery charge max for comparison self.last_bat_charge_max = self.current_bat_charge_max - self.current_overall_state = new_state def set_current_battery_soc(self, value): @@ -397,11 +471,27 @@ def set_current_battery_soc(self, value): """ self.current_battery_soc = value # logger.debug("[BASE-CTRL] set current battery SOC to %s", value) + # logger.debug("[BASE-CTRL] set current battery SOC to %s", value) + + def set_override_charge_rate(self, charge_rate): + """ + Sets the override charge rate. + """ + self.override_charge_rate = charge_rate + logger.debug("[BASE-CTRL] set override charge rate to %s", charge_rate) + + def set_override_duration(self, duration): + """ + Sets the override duration. + """ + self.override_duration = duration + logger.debug("[BASE-CTRL] set override duration to %s", duration) - def set_mode_override(self, mode, duration, charge_rate): + def set_mode_override(self, mode): """ Sets the current overall state to a specific mode. """ + duration = self.override_duration # switch back to EOS given demands if mode == -2: self.clear_mode_override() @@ -412,6 +502,7 @@ def set_mode_override(self, mode, duration, charge_rate): duration_seconds = duration * 60 # duration_seconds = duration * 60 / 10 else: + logger.error("[BASE-CTRL] OVERRIDE invalid duration %s", duration) logger.error("[BASE-CTRL] OVERRIDE invalid duration %s", duration) return @@ -421,26 +512,31 @@ def set_mode_override(self, mode, duration, charge_rate): self.override_end_time = (time.time() + duration_seconds) // 60 * 60 self._state_change_timestamps.append(time.time()) logger.info( + "[BASE-CTRL] OVERRIDE set overall state to %s with endtime %s", "[BASE-CTRL] OVERRIDE set overall state to %s with endtime %s", state_mapping[mode], datetime.fromtimestamp( self.override_end_time, self.time_zone ).isoformat(), ) - if charge_rate > 0 and mode == MODE_CHARGE_FROM_GRID: - self.current_ac_charge_demand = charge_rate * 1000 + if self.override_charge_rate > 0 and mode == MODE_CHARGE_FROM_GRID: + self.current_ac_charge_demand = self.override_charge_rate * 1000 logger.info( + "[BASE-CTRL] OVERRIDE set AC charge demand to %s", "[BASE-CTRL] OVERRIDE set AC charge demand to %s", self.current_ac_charge_demand, ) - if charge_rate > 0 and mode == MODE_DISCHARGE_ALLOWED: - self.current_dc_charge_demand = charge_rate * 1000 + if self.override_charge_rate > 0 and mode == MODE_DISCHARGE_ALLOWED: + self.current_dc_charge_demand = self.override_charge_rate * 1000 logger.info( + "[BASE-CTRL] OVERRIDE set DC charge demand to %s", "[BASE-CTRL] OVERRIDE set DC charge demand to %s", self.current_dc_charge_demand, ) + self.override_active_since = time.time() else: logger.error("[BASE-CTRL] OVERRIDE invalid mode %s", mode) + logger.error("[BASE-CTRL] OVERRIDE invalid mode %s", mode) def clear_mode_override(self): """ @@ -453,6 +549,7 @@ def clear_mode_override(self): self.__set_current_overall_state() # reset the override end time to 0 logger.info("[BASE-CTRL] cleared mode override") + logger.info("[BASE-CTRL] cleared mode override") def __start_update_service(self): """ @@ -465,6 +562,7 @@ def __start_update_service(self): ) self._update_thread.start() logger.info("[BASE-CTRL] Update service started.") + logger.info("[BASE-CTRL] Update service started.") def shutdown(self): """ @@ -474,6 +572,7 @@ def shutdown(self): self._stop_event.set() self._update_thread.join() logger.info("[BASE-CTRL] Update service stopped.") + logger.info("[BASE-CTRL] Update service stopped.") def __update_base_control_loop(self): """ diff --git a/src/interfaces/battery_interface.py b/src/interfaces/battery_interface.py index 1b7ecff5..7934ba66 100644 --- a/src/interfaces/battery_interface.py +++ b/src/interfaces/battery_interface.py @@ -75,6 +75,9 @@ def __init__(self, config, on_bat_max_changed=None): self.current_soc = 0 self.current_usable_capacity = 0 self.on_bat_max_changed = on_bat_max_changed + self.min_soc_set = config.get("min_soc_percentage", 0) + self.max_soc_set = config.get("max_soc_percentage", 100) + self.soc_fail_count = 0 self.update_interval = 30 @@ -114,6 +117,9 @@ def __fetch_soc_data_from_openhab(self): "[BATTERY-IF] Detected decimal format (0.0-1.0): %s -> %s%%", raw_value, soc, + "[BATTERY-IF] Detected decimal format (0.0-1.0): %s -> %s%%", + raw_value, + soc, ) else: soc = raw_value # Already in percentage format @@ -121,13 +127,21 @@ def __fetch_soc_data_from_openhab(self): "[BATTERY-IF] Detected percentage format (0-100): %s%%", soc ) self.soc_fail_count = 0 # Reset fail count on success + return round(soc, 1) + logger.debug( + "[BATTERY-IF] Detected percentage format (0-100): %s%%", soc + ) + self.soc_fail_count = 0 # Reset fail count on success return round(soc, 1) except requests.exceptions.Timeout: + return self._handle_soc_error( + "openhab", "Request timed out", self.current_soc return self._handle_soc_error( "openhab", "Request timed out", self.current_soc ) except requests.exceptions.RequestException as e: return self._handle_soc_error("openhab", e, self.current_soc) + return self._handle_soc_error("openhab", e, self.current_soc) def __fetch_soc_data_from_homeassistant(self): """ @@ -155,13 +169,18 @@ def __fetch_soc_data_from_homeassistant(self): entity_data = response.json() soc = float(entity_data["state"]) self.soc_fail_count = 0 # Reset fail count on success + self.soc_fail_count = 0 # Reset fail count on success return round(soc, 1) except requests.exceptions.Timeout: return self._handle_soc_error( "homeassistant", "Request timed out", self.current_soc ) + return self._handle_soc_error( + "homeassistant", "Request timed out", self.current_soc + ) except requests.exceptions.RequestException as e: return self._handle_soc_error("homeassistant", e, self.current_soc) + return self._handle_soc_error("homeassistant", e, self.current_soc) def __battery_request_current_soc(self): """ @@ -169,12 +188,17 @@ def __battery_request_current_soc(self): """ # default value for start SOC = 5 default = False + default = False if self.src == "default": + self.current_soc = 5 + default = True self.current_soc = 5 default = True logger.debug("[BATTERY-IF] source set to default with start SOC = 5%") + elif self.src == "openhab": elif self.src == "openhab": self.current_soc = self.__fetch_soc_data_from_openhab() + elif self.src == "homeassistant": elif self.src == "homeassistant": self.current_soc = self.__fetch_soc_data_from_homeassistant() else: @@ -184,7 +208,9 @@ def __battery_request_current_soc(self): "[BATTERY-IF] source currently not supported. Using default start SOC = 5%." ) if default is False: - logger.debug("[BATTERY-IF] successfully fetched SOC = %s %%", self.current_soc) + logger.debug( + "[BATTERY-IF] successfully fetched SOC = %s %%", self.current_soc + ) return self.current_soc def _handle_soc_error(self, source, error, last_soc): @@ -225,6 +251,64 @@ def get_current_usable_capacity(self): """ return round(self.current_usable_capacity, 2) + def get_min_soc(self): + """ + Returns the minimum state of charge (SOC) percentage of the battery. + """ + return self.min_soc_set + + def set_min_soc(self, min_soc): + """ + Sets the minimum state of charge (SOC) percentage of the battery. + """ + # check that min_soc is not greater than max_soc and not less than configured min_soc + if min_soc > self.max_soc_set: + logger.warning( + "[BATTERY-IF] Attempted to set min SOC (%s) higher than max SOC (%s)." + + " Adjusting min SOC to max SOC.", + min_soc, + self.max_soc_set, + ) + min_soc = self.max_soc_set - 1 + if min_soc < self.battery_data.get("min_soc_percentage", 0): + logger.warning( + "[BATTERY-IF] Attempted to set min SOC (%s) lower than configured min SOC (%s)." + + " setting to configured min SOC.", + min_soc, + self.battery_data.get("min_soc_percentage", 0), + ) + min_soc = self.battery_data.get("min_soc_percentage", 0) + self.min_soc_set = min_soc + + def get_max_soc(self): + """ + Returns the maximum state of charge (SOC) percentage of the battery. + """ + return self.max_soc_set + + def set_max_soc(self, max_soc): + """ + Sets the maximum state of charge (SOC) percentage of the battery. + """ + # check that max_soc is not less than min_soc and not greater than configured max_soc + if max_soc < self.min_soc_set: + logger.warning( + "[BATTERY-IF] Attempted to set max SOC (%s) lower than min SOC (%s)." + + " Adjusting max SOC to min SOC.", + max_soc, + self.min_soc_set, + ) + max_soc = self.min_soc_set + 1 + if max_soc > self.battery_data.get("max_soc_percentage", 100): + logger.warning( + "[BATTERY-IF] Attempted to set max SOC (%s) higher than configured max SOC (%s)." + + " setting to configured max SOC.", + max_soc, + self.battery_data.get("max_soc_percentage", 100), + ) + max_soc = self.battery_data.get("max_soc_percentage", 100) + self.max_soc_set = max_soc + def __get_max_charge_power_dyn(self, soc=None, min_charge_power=500): """ Calculates the maximum charge power of the battery dynamically based on SOC @@ -278,6 +362,7 @@ def __get_max_charge_power_dyn(self, soc=None, min_charge_power=500): else: # Logarithmic decrease of C-rate after 50% SOC c_rate = max(min_c_rate, max_c_rate * (1 - (soc - 50) / 60) ** 2) + c_rate = max(min_c_rate, max_c_rate * (1 - (soc - 50) / 60) ** 2) # Calculate the maximum charge power in watts max_charge_power = c_rate * battery_capacity_wh @@ -294,6 +379,8 @@ def __get_max_charge_power_dyn(self, soc=None, min_charge_power=500): logger.info( "[BATTERY-IF] Max dynamic charge power changed to %s W", self.max_charge_power_dyn, + "[BATTERY-IF] Max dynamic charge power changed to %s W", + self.max_charge_power_dyn, ) if self.on_bat_max_changed: self.on_bat_max_changed() @@ -326,15 +413,18 @@ def _update_state_loop(self): while not self._stop_event.is_set(): try: self.__battery_request_current_soc() - self.current_usable_capacity = max(0, ( - self.battery_data.get("capacity_wh", 0) - * self.battery_data.get("discharge_efficiency", 1.0) - * ( - self.current_soc - - self.battery_data.get("min_soc_percentage", 0) - ) - / 100 - )) + self.current_usable_capacity = max( + 0, + ( + self.battery_data.get("capacity_wh", 0) + * self.battery_data.get("discharge_efficiency", 1.0) + * ( + self.current_soc + - self.battery_data.get("min_soc_percentage", 0) + ) + / 100 + ), + ) self.__get_max_charge_power_dyn() except (requests.exceptions.RequestException, ValueError, KeyError) as e: diff --git a/src/interfaces/eos_interface.py b/src/interfaces/eos_interface.py deleted file mode 100644 index 9314c027..00000000 --- a/src/interfaces/eos_interface.py +++ /dev/null @@ -1,596 +0,0 @@ -""" -This module provides an interface for interacting with an EOS server. -The `EosInterface` class includes methods for setting configuration values, -sending measurement data, sending optimization requests, saving configurations -to a file, and updating configurations from a file. It uses HTTP requests to -communicate with the EOS server. -Classes: - EosInterface: A class that provides methods to interact with the EOS server. -Dependencies: - - logging: For logging messages. - - time: For measuring elapsed time. - - json: For handling JSON data. - - datetime: For working with date and time. - - requests: For making HTTP requests. -Usage: - Create an instance of the `EosInterface` class by providing the EOS server - address, port, and timezone. Use the provided methods to interact with the - EOS server for various operations such as setting configuration values, - sending measurement data, and managing configurations. -""" - -import logging -import time -import json -from datetime import datetime, timedelta -import requests -import pandas as pd -import numpy as np - -logger = logging.getLogger("__main__") -logger.info("[EOS] loading module ") - - -# EOS_API_PUT_LOAD_SERIES = { -# f"http://{EOS_SERVER}:{EOS_SERVER_PORT}/v1/measurement/load-mr/series/by-name" # -# } # ?name=Household - -# EOS_API_GET_CONFIG_VALUES = {f"http://{EOS_SERVER}:{EOS_SERVER_PORT}/v1/config"} - -# EOS_API_PUT_LOAD_PROFILE = { -# f"http://{EOS_SERVER}:{EOS_SERVER_PORT}/v1/measurement/load-mr/value/by-name" -# } - - -class EosInterface: - """ - EosInterface is a class that provides an interface for interacting with an EOS server. - This class includes methods for setting configuration values, sending measurement data, - sending optimization requests, saving configurations to a file, and updating configurations - from a file. It uses HTTP requests to communicate with the EOS server. - Attributes: - eos_server (str): The hostname or IP address of the EOS server. - eos_port (int): The port number of the EOS server. - base_url (str): The base URL constructed from the server and port. - time_zone (timezone): The timezone used for time-related operations. - Methods: - set_config_value(key, value): - send_measurement_to_eos(dataframe): - Send measurement data to the EOS server. - eos_set_optimize_request(payload, timeout=180): - Send an optimization request to the EOS server. - eos_save_config_to_config_file(): - eos_update_config_from_config_file(): - """ - - def __init__(self, eos_server, eos_port, timezone): - self.eos_server = eos_server - self.eos_port = eos_port - self.base_url = f"http://{eos_server}:{eos_port}" - self.time_zone = timezone - self.last_start_solution = None - self.home_appliance_released = False - self.home_appliance_start_hour = None - self.eos_version = ( - ">=2025-04-09" # use as default value in case version check fails - ) - self.eos_version = self.__retrieve_eos_version() - - self.last_control_data = [ - { - "ac_charge_demand": 0, - "dc_charge_demand": 0, - "discharge_allowed": False, - "error": 0, - "hour": -1, - }, - { - "ac_charge_demand": 0, - "dc_charge_demand": 0, - "discharge_allowed": False, - "error": 0, - "hour": -1, - }, - ] - - self.last_optimization_runtimes = [0] * 5 # list to store last 5 runtimes - self.last_optimization_runtime_number = 0 # index for circular list - self.is_first_run = True # Add flag to track first run - - # EOS basic API helper - def set_config_value(self, key, value): - """ - Set a configuration value on the EOS server. - """ - if isinstance(value, list): - value = json.dumps(value) - params = {"key": key, "value": value} - response = requests.put( - self.base_url + "/v1/config/value", params=params, timeout=10 - ) - response.raise_for_status() - logger.info( - "[EOS] Config value set successfully. Key: {key} \t\t => Value: {value}" - ) - - def send_measurement_to_eos(self, dataframe): - """ - Send the measurement data to the EOS server. - """ - params = { - "data": dataframe.to_json(orient="index"), - "dtype": "float64", - "tz": "UTC", - } - response = requests.put( - self.base_url - + "/v1/measurement/load-mr/series/by-name" - + "?name=Household", - params=params, - timeout=10, - ) - response.raise_for_status() - if response.status_code == 200: - logger.debug("[EOS] Measurement data sent to EOS server successfully.") - else: - logger.debug( - "[EOS]" - "Failed to send data to EOS server. Status code: {response.status_code}" - ", Response: {response.text}" - ) - - def eos_set_optimize_request(self, payload, timeout=180): - """ - Send the optimize request to the EOS server. - """ - headers = {"accept": "application/json", "Content-Type": "application/json"} - request_url = ( - self.base_url - + "/optimize" - + "?start_hour=" - + str(datetime.now(self.time_zone).hour) - ) - logger.info( - "[EOS] OPTIMIZE request optimization with: %s - and with timeout: %s", - request_url, - timeout, - ) - response = None # Initialize response variable - try: - start_time = time.time() - response = requests.post( - request_url, headers=headers, json=payload, timeout=timeout - ) - end_time = time.time() - elapsed_time = end_time - start_time - minutes, seconds = divmod(elapsed_time, 60) - logger.info( - "[EOS] OPTIMIZE response retrieved successfully in %d min %.2f sec for current run", - int(minutes), - seconds, - ) - response.raise_for_status() - # Check if the array is still filled with zeros - if all(runtime == 0 for runtime in self.last_optimization_runtimes): - # Fill all entries with the first real value - self.last_optimization_runtimes = [elapsed_time] * 5 - else: - # Store the runtime in the circular list only if successful - self.last_optimization_runtimes[ - self.last_optimization_runtime_number - ] = elapsed_time - self.last_optimization_runtime_number = ( - self.last_optimization_runtime_number + 1 - ) % 5 - # logger.debug( - # "[EOS] OPTIMIZE Last 5 runtimes in seconds: %s", - # self.last_optimization_runtimes, - # ) - avg_runtime = sum(self.last_optimization_runtimes) / 5 - return response.json(), avg_runtime - except requests.exceptions.Timeout: - logger.error("[EOS] OPTIMIZE Request timed out after %s seconds", timeout) - return {"error": "Request timed out - trying again with next run"} - except requests.exceptions.ConnectionError as e: - logger.error( - "[EOS] OPTIMIZE Connection error - EOS server not reachable at %s " - + "will try again with next cycle - error: %s", - request_url, - str(e), - ) - return { - "error": f"EOS server not reachable at {self.base_url} " - + "will try again with next cycle" - } - except requests.exceptions.RequestException as e: - logger.error("[EOS] OPTIMIZE Request failed: %s", e) - if response is not None: - logger.error("[EOS] OPTIMIZE Response status: %s", response.status_code) - logger.debug( - "[EOS] OPTIMIZE ERROR - response of EOS is:" - + "\n---RESPONSE-------------------------------------------------\n %s" - + "\n------------------------------------------------------------", - response.text, - ) - logger.debug( - "[EOS] OPTIMIZE ERROR - payload for the request was:" - + "\n---REQUEST--------------------------------------------------\n %s" - + "\n------------------------------------------------------------", - payload, - ) - return {"error": str(e)} - - def examine_response_to_control_data(self, optimized_response_in): - """ - Examines the optimized response data for control parameters such as AC charge demand, - DC charge demand, and discharge allowance for the current hour. - Args: - optimized_response_in (dict): A dictionary containing control data with keys - "ac_charge", "dc_charge", and "discharge_allowed". - Each key maps to a list or dictionary where the - current hour's data can be accessed. - Returns: - tuple: A tuple containing: - - ac_charge_demand_relative (float or None): The AC charge demand percentage - for the current hour, or None if not present. - - dc_charge_demand_relative (float or None): The DC charge demand percentage - for the current hour, or None if not present. - - discharge_allowed (bool or None): Whether discharge is allowed for the - current hour, or None if not present. - Logs: - - Debug logs for AC charge demand, DC charge demand, and discharge allowance - values for the current hour if they are present in the input. - - An error log if no control data is found in the optimized response. - """ - current_hour = datetime.now(self.time_zone).hour - ac_charge_demand_relative = None - dc_charge_demand_relative = None - discharge_allowed = None - response_error = False - # ecar_response = None - if "ac_charge" in optimized_response_in: - ac_charge_demand_relative = optimized_response_in["ac_charge"] - self.last_control_data[0]["ac_charge_demand"] = ac_charge_demand_relative[ - current_hour - ] - self.last_control_data[1]["ac_charge_demand"] = ac_charge_demand_relative[ - current_hour + 1 if current_hour < 23 else 0 - ] - # getting entry for current hour - ac_charge_demand_relative = ac_charge_demand_relative[current_hour] - logger.debug( - "[EOS] RESPONSE AC charge demand for current hour %s:00 -> %s %%", - current_hour, - ac_charge_demand_relative * 100, - ) - if "dc_charge" in optimized_response_in: - dc_charge_demand_relative = optimized_response_in["dc_charge"] - self.last_control_data[0]["dc_charge_demand"] = dc_charge_demand_relative[ - current_hour - ] - self.last_control_data[1]["dc_charge_demand"] = dc_charge_demand_relative[ - current_hour + 1 if current_hour < 23 else 0 - ] - - # getting entry for current hour - dc_charge_demand_relative = dc_charge_demand_relative[current_hour] - logger.debug( - "[EOS] RESPONSE DC charge demand for current hour %s:00 -> %s %%", - current_hour, - dc_charge_demand_relative * 100, - ) - if "discharge_allowed" in optimized_response_in: - discharge_allowed = optimized_response_in["discharge_allowed"] - self.last_control_data[0]["discharge_allowed"] = discharge_allowed[ - current_hour - ] - self.last_control_data[1]["discharge_allowed"] = discharge_allowed[ - current_hour + 1 if current_hour < 23 else 0 - ] - # getting entry for current hour - discharge_allowed = bool(discharge_allowed[current_hour]) - logger.debug( - "[EOS] RESPONSE Discharge allowed for current hour %s:00 %s", - current_hour, - discharge_allowed, - ) - # if "eauto_obj" in optimized_response_in: - # eauto_obj = optimized_response_in["eauto_obj"] - - if ( - "start_solution" in optimized_response_in - and len(optimized_response_in["start_solution"]) > 1 - ): - self.set_last_start_solution(optimized_response_in["start_solution"]) - logger.debug( - "[EOS] RESPONSE Start solution for current hour %s:00 %s", - current_hour, - self.get_last_start_solution(), - ) - else: - logger.error("[EOS] RESPONSE No control data in optimized response") - response_error = True - - self.last_control_data[0]["error"] = int(response_error) - self.last_control_data[1]["error"] = int(response_error) - self.last_control_data[0]["hour"] = current_hour - self.last_control_data[1]["hour"] = current_hour + 1 if current_hour < 23 else 0 - - if "washingstart" in optimized_response_in: - self.home_appliance_start_hour = optimized_response_in["washingstart"] - if self.home_appliance_start_hour == current_hour: - self.home_appliance_released = True - else: - self.home_appliance_released = False - logger.debug( - "[EOS] RESPONSE Home appliance - current hour %s:00" - + " - start hour %s - is Released: %s", - current_hour, - self.home_appliance_start_hour, - self.home_appliance_released, - ) - - return ( - ac_charge_demand_relative, - dc_charge_demand_relative, - discharge_allowed, - response_error, - ) - - def eos_save_config_to_config_file(self): - """ - Save the current configuration to the configuration file on the EOS server. - """ - response = requests.put(self.base_url + "/v1/config/file", timeout=10) - response.raise_for_status() - logger.debug("[EOS] CONFIG saved to config file successfully.") - - def eos_update_config_from_config_file(self): - """ - Update the current configuration from the configuration file on the EOS server. - """ - try: - response = requests.post(self.base_url + "/v1/config/update", timeout=10) - response.raise_for_status() - logger.info("[EOS] CONFIG Config updated from config file successfully.") - except requests.exceptions.Timeout: - logger.error( - "[EOS] CONFIG Request timed out while updating config from config file." - ) - except requests.exceptions.RequestException as e: - logger.error( - "[EOS] CONFIG Request failed while updating config from config file: %s", - e, - ) - - def get_last_control_data(self): - """ - Get the last control data for the EOS interface. - - Returns: - list: The last control data. - """ - return self.last_control_data - - def set_last_start_solution(self, last_start_solution): - """ - Set the last start solution for the EOS interface. - - Args: - last_start_solution (str): The last start solution to set. - """ - self.last_start_solution = last_start_solution - - def get_last_start_solution(self): - """ - Get the last start solution for the EOS interface. - - Returns: - str: The last start solution. - """ - return self.last_start_solution - - def get_home_appliance_released(self): - """ - Get the home appliance released status. - - Returns: - bool: True if the home appliance is released, False otherwise. - """ - return self.home_appliance_released - - def get_home_appliance_start_hour(self): - """ - Get the home appliance start hour. - - Returns: - int: The hour when the home appliance starts. - """ - return self.home_appliance_start_hour - - # function that creates a pandas dataframe with a DateTimeIndex with the given average profile - def create_dataframe(self, profile): - """ - Creates a pandas DataFrame with hourly energy values for a given profile. - - Args: - profile (list of tuples): A list of tuples where each tuple contains: - - month (int): The month (1-12). - - weekday (int): The day of the week (0=Monday, 6=Sunday). - - hour (int): The hour of the day (0-23). - - energy (float): The energy value to set. - - Returns: - pandas.DataFrame: A DataFrame with a DateTime index for the year 2025 and a 'Household' - column containing the energy values from the profile. - """ - - # create a list of all dates in the year - dates = pd.date_range(start="1/1/2025", end="31/12/2025", freq="H") - # create an empty dataframe with the dates as index - df = pd.DataFrame(index=dates) - # add a column 'Household' to the dataframe with NaN values - df["Household"] = np.nan - # iterate over the profile and set the energy values in the dataframe - for entry in profile: - month = entry[0] - weekday = entry[1] - hour = entry[2] - energy = entry[3] - # get the dates that match the month, weekday and hour - dates = df[ - (df.index.month == month) - & (df.index.weekday == weekday) - & (df.index.hour == hour) - ].index - # set the energy value for the dates - for date in dates: - df.loc[date, "Household"] = energy - return df - - def __retrieve_eos_version(self): - """ - Get the EOS version from the server. Dirty hack to get something to distinguish between - different versions of the EOS server. - - Returns: - str: The EOS version. - """ - try: - response = requests.get(self.base_url + "/v1/health", timeout=10) - response.raise_for_status() - eos_version = response.json().get("status") - if eos_version == "alive": - eos_version = ">=2025-04-09" - logger.info("[EOS] Getting EOS version: %s", eos_version) - return eos_version - except requests.exceptions.HTTPError as e: - if e.response.status_code == 404: - # if not found, assume version < 2025-04-09 - eos_version = "<2025-04-09" - logger.info("[EOS] Getting EOS version: %s", eos_version) - return eos_version - else: - logger.error( - "[EOS] HTTP error occurred while getting EOS version" - + " - use preset version: %s : %s - Response: %s", - self.eos_version, - e, - e.response.text if e.response else "No response", - ) - return self.eos_version # return preset version if error occurs - except requests.exceptions.ConnectTimeout: - logger.error( - "[EOS] Failed to get EOS version - use preset version: '%s'" - + " - Server not reachable: Connection to %s timed out", - self.eos_version, - self.base_url, - ) - return self.eos_version # return preset version if error occurs - except requests.exceptions.ConnectionError as e: - logger.error( - "[EOS] Failed to get EOS version - use preset version: '%s' - Connection error: %s", - self.eos_version, - e, - ) - return self.eos_version # return preset version if error occurs - except requests.exceptions.RequestException as e: - logger.error( - "[EOS] Failed to get EOS version - use preset version: '%s' - Error: %s ", - self.eos_version, - e, - ) - return self.eos_version # return preset version if error occurs - except json.JSONDecodeError as e: - logger.error( - "[EOS] Failed to decode EOS version - use preset version: '%s' - response: %s ", - self.eos_version, - e, - ) - return self.eos_version # return preset version if error occurs - - def get_eos_version(self): - """ - Get the EOS version from the server. - - Returns: - str: The EOS version. - """ - return self.eos_version - - def calculate_next_run_time(self, current_time, avg_runtime, update_interval): - """ - Calculate the next run time prioritizing quarter-hour alignment with improved gap filling. - """ - # Calculate minimum time between runs - min_gap_seconds = max((update_interval + avg_runtime) * 0.7, 30) - - # Find next quarter-hour from current time - next_quarter = current_time.replace(second=0, microsecond=0) - current_minute = next_quarter.minute - - minutes_past_quarter = current_minute % 15 - if minutes_past_quarter == 0 and current_time.second > 0: - minutes_to_add = 15 - elif minutes_past_quarter == 0: - minutes_to_add = 15 - else: - minutes_to_add = 15 - minutes_past_quarter - - next_quarter += timedelta(minutes=minutes_to_add) - - quarter_aligned_start = next_quarter - timedelta(seconds=avg_runtime) - - # **BUG FIX**: Check if quarter_aligned_start is in the past - if quarter_aligned_start <= current_time: - # Move to the next quarter-hour - next_quarter += timedelta(minutes=15) - quarter_aligned_start = next_quarter - timedelta(seconds=avg_runtime) - logger.debug( - "[OPTIMIZATION] Quarter start was in past, moved to next: %s", - next_quarter.strftime("%H:%M:%S"), - ) - - time_until_quarter_start = ( - quarter_aligned_start - current_time - ).total_seconds() - - # Debug logging - logger.debug( - "[OPTIMIZATION] Debug: current=%s, next_quarter=%s, quarter_start=%s, time_until=%.1fs", - current_time.strftime("%H:%M:%S"), - next_quarter.strftime("%H:%M:%S"), - quarter_aligned_start.strftime("%H:%M:%S"), - time_until_quarter_start, - ) - - # More aggressive gap-filling: if we have at least 2x the update interval, - # try a gap-fill run - if ( - time_until_quarter_start >= (2 * update_interval) - and time_until_quarter_start >= min_gap_seconds - ): - normal_next_start = current_time + timedelta(seconds=update_interval) - logger.info( - "[OPTIMIZATION] Gap-fill run: start %s (quarter-aligned run follows at %s)", - normal_next_start.strftime("%H:%M:%S"), - next_quarter.strftime("%H:%M:%S"), - ) - return normal_next_start - - # Otherwise, use quarter-aligned timing - absolute_min_seconds = max(avg_runtime * 0.5, 30) - if time_until_quarter_start < absolute_min_seconds: - next_quarter += timedelta(minutes=15) - quarter_aligned_start = next_quarter - timedelta(seconds=avg_runtime) - logger.debug( - "[OPTIMIZATION] Quarter too close, moved to next: %s", - next_quarter.strftime("%H:%M:%S"), - ) - - logger.info( - "[OPTIMIZATION] Quarter-hour aligned run: start %s, finish at %s", - quarter_aligned_start.strftime("%H:%M:%S"), - next_quarter.strftime("%H:%M:%S"), - ) - return quarter_aligned_start diff --git a/src/interfaces/evcc_interface.py b/src/interfaces/evcc_interface.py index 96454043..510422d3 100644 --- a/src/interfaces/evcc_interface.py +++ b/src/interfaces/evcc_interface.py @@ -37,7 +37,9 @@ "minpv": 2, "pv+now": 3, "minpv+now": 4, - "now": 5, + "pv+plan": 5, + "minpv+plan": 6, + "now": 7, } @@ -102,6 +104,7 @@ def __init__( "vehicleOdometer": 0, "vehicleName": "", "smartCostActive": False, + "planActive": False, } ] self.external_battery_mode_en = ext_bat_mode @@ -212,6 +215,7 @@ def __get_default_detail_data(self): "vehicleOdometer": 0, "vehicleName": "", "smartCostActive": False, + "planActive": False, } ] @@ -330,6 +334,7 @@ def __get_states_modes_of_connected_loadpoints(self, loadpoints): "charging": lp.get("charging", False), "mode": lp.get("mode", "off"), "smartCostActive": lp.get("smartCostActive", False), + "planActive": lp.get("planActive", False), } ) # logger.debug( @@ -343,13 +348,14 @@ def __get_summerized_charging_state_n_mode(self, collected_states_modes): sum_mode_priority = 0 sum_charging_mode = "off" sum_charging_state = False - sum_smart_cost_active = False for entry in collected_states_modes: if entry["charging"]: mode = entry["mode"] sum_charging_state = True if mode in ("pv", "minpv") and entry.get("smartCostActive", False): mode = mode + "+now" + if mode in ("pv", "minpv") and entry.get("planActive", False): + mode = mode + "+plan" if sum_mode_priority < CHARGING_MODE_PRIORITY[mode]: sum_mode_priority = CHARGING_MODE_PRIORITY[mode] sum_charging_mode = mode @@ -364,6 +370,10 @@ def __get_summerized_charging_state_n_mode(self, collected_states_modes): "smartCostActive", False ): sum_charging_mode = sum_charging_mode + "+now" + if sum_charging_mode in ("pv", "minpv") and collected_states_modes[0].get( + "planActive", False + ): + sum_charging_mode = sum_charging_mode + "+plan" # logger.debug( # "[EVCC] No charging loadpoints found." @@ -406,6 +416,8 @@ def __get_states_of_loadpoints(self, loadpoints, vehicles): mode = loadpoint.get("mode", "off") if mode in ("pv", "minpv") and loadpoint.get("smartCostActive", False): mode = mode + "+now" + if mode in ("pv", "minpv") and loadpoint.get("planActive", False): + mode = mode + "+plan" detail_data = { "connected": loadpoint.get("connected", False), "charging": loadpoint.get("charging", False), @@ -420,6 +432,7 @@ def __get_states_of_loadpoints(self, loadpoints, vehicles): "vehicleOdometer": loadpoint.get("vehicleOdometer", 0), "vehicleName": vehicle_name, "smartCostActive": loadpoint.get("smartCostActive", False), + "planActive": loadpoint.get("planActive", False), } self.current_detail_data_list.append(detail_data) return True diff --git a/src/interfaces/inverter_fronius_v2.py b/src/interfaces/inverter_fronius_v2.py index 7fd80390..489fe378 100644 --- a/src/interfaces/inverter_fronius_v2.py +++ b/src/interfaces/inverter_fronius_v2.py @@ -637,7 +637,8 @@ def set_mode_force_charge(self, charge_power_w): max_power = min(self.max_grid_charge_rate, 10000) charge_power = min(int(charge_power_w), max_power) - if charge_power != charge_power_w: + # Only warn if the value was actually limited by max_power, not just rounded + if charge_power_w > max_power: logger.warning( f"[InverterV2] Charge power limited from {charge_power_w}W to {charge_power}W" ) diff --git a/src/interfaces/load_interface.py b/src/interfaces/load_interface.py index 94c3eae5..13e08e4f 100644 --- a/src/interfaces/load_interface.py +++ b/src/interfaces/load_interface.py @@ -4,13 +4,17 @@ load profiles based on historical energy consumption data. """ +from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone import logging from urllib.parse import quote -import zoneinfo +import time +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +import random import requests import pytz + logger = logging.getLogger("__main__") logger.info("[LOAD-IF] loading module ") @@ -26,6 +30,7 @@ def __init__( self, config, tz_name=None, # Changed default to None + tz_name=None, # Changed default to None ): self.src = config.get("source", "") self.url = config.get("url", "") @@ -34,25 +39,37 @@ def __init__( self.additional_load_1_sensor = config.get("additional_load_1_sensor", "") self.access_token = config.get("access_token", "") + # retry config + self.max_retries = config.get("max_retries", 5) + self.retry_backoff = config.get("retry_backoff", 1) # base seconds for backoff + # optional warning threshold (when to escalate to error) + self.warning_threshold = config.get( + "warning_threshold", max(1, self.max_retries - 1) + ) + + self.time_zone = None + # Handle timezone properly + if tz_name == "UTC" or tz_name is None: if tz_name == "UTC" or tz_name is None: self.time_zone = None # Use local timezone + elif isinstance(tz_name, str): elif isinstance(tz_name, str): # Try to convert string timezone to proper timezone object try: - self.time_zone = zoneinfo.ZoneInfo(tz_name) - except ImportError: - # Fallback for older Python versions + # zoneinfo.ZoneInfo may raise ZoneInfoNotFoundError + self.time_zone = ZoneInfo(tz_name) + except ZoneInfoNotFoundError: + # fallback to pytz if available, otherwise use local (None) try: self.time_zone = pytz.timezone(tz_name) - except ImportError: + except pytz.UnknownTimeZoneError: logger.warning( "[LOAD-IF] Cannot parse timezone '%s', using local time", tz_name, + tz_name, ) self.time_zone = None - else: - self.time_zone = tz_name self.__check_config() @@ -93,6 +110,70 @@ def __check_config(self): logger.debug("[LOAD-IF] Using default load profile.") return True + def __log_request_failure(self, url, attempt, max_retries, error, item_label=""): + """ + Centralized logging for request failures. + Logs a warning for intermediate failed attempts and an error when all attempts exhausted. + """ + # Only log warning for the pre-last attempt, error for the last + if attempt == max_retries - 1: + logger.warning( + "[LOAD-IF] Request attempt %d/%d failed for %s %s: %s", + attempt, + max_retries, + url, + f"({item_label})" if item_label else "", + str(error), + ) + elif attempt == max_retries: + logger.error( + "[LOAD-IF] Request failed after %d attempts for %s %s: %s", + max_retries, + url, + f"({item_label})" if item_label else "", + str(error), + ) + else: + logger.debug( + "[LOAD-IF] Request attempt %d/%d failed for %s %s: %s", + attempt, + max_retries, + url, + f"({item_label})" if item_label else "", + str(error), + ) + + def __request_with_retries( + self, method, url, params=None, headers=None, timeout=10, item_label="" + ): + """ + Perform an HTTP request with retries and exponential backoff. + Returns the requests.Response on success, or None on final failure. + """ + attempt = 0 + while attempt < self.max_retries: + attempt += 1 + try: + if method.lower() == "get": + response = requests.get( + url, params=params, headers=headers, timeout=timeout + ) + else: + response = requests.request( + method, url, params=params, headers=headers, timeout=timeout + ) + response.raise_for_status() + return response + except requests.exceptions.RequestException as e: + self.__log_request_failure( + url, attempt, self.max_retries, e, item_label + ) + if attempt == self.max_retries: + return None + sleep_seconds = self.retry_backoff * (2 ** (attempt - 1)) + sleep_seconds = sleep_seconds + random.uniform(0, sleep_seconds * 0.5) + time.sleep(sleep_seconds) + # get load data from url persistance source def __fetch_historical_energy_data_from_openhab( self, openhab_item, start_time, end_time @@ -101,23 +182,22 @@ def __fetch_historical_energy_data_from_openhab( Fetch energy data from the specified OpenHAB item URL within the given time range. """ if openhab_item == "": - return {"data": []} + return [] openhab_item_url = self.url + "/rest/persistence/items/" + openhab_item params = {"starttime": start_time.isoformat(), "endtime": end_time.isoformat()} + response = self.__request_with_retries( + "get", openhab_item_url, params=params, timeout=10, item_label=openhab_item + ) + if response is None: + # Do not log error here; already logged in __request_with_retries + return [] try: - response = requests.get(openhab_item_url, params=params, timeout=10) - response.raise_for_status() - # logger.debug( - # "[LOAD-IF] OPENHAB - Fetched data from %s to %s", - # start_time.isoformat(), - # end_time.isoformat() - # ) - historical_data = (response.json())["data"] - # Extract only 'state' and 'last_updated' from the historical data filtered_data = [ { "state": entry["state"], + "last_updated": datetime.fromtimestamp( + entry["time"] / 1000, tz=timezone.utc "last_updated": datetime.fromtimestamp( entry["time"] / 1000, tz=timezone.utc ).isoformat(), @@ -125,16 +205,10 @@ def __fetch_historical_energy_data_from_openhab( for entry in historical_data ] return filtered_data - except requests.exceptions.Timeout: - logger.error( - "[LOAD-IF] OPENHAB - Request timed out while fetching energy data." - ) - return {"data": []} - except requests.exceptions.RequestException as e: - logger.error( - "[LOAD-IF] OPENHAB - Request failed while fetching energy data: %s", e - ) - return {"data": []} + except (ValueError, KeyError, TypeError) as e: + # Only log if it's a JSON or data processing error, not a request error + logger.error("[LOAD-IF] OPENHAB - Failed to process energy data: %s", e) + return [] def __fetch_historical_energy_data_from_homeassistant( self, entity_id, start_time, end_time @@ -151,61 +225,84 @@ def __fetch_historical_energy_data_from_homeassistant( list: A list of historical state changes for the entity. """ if entity_id == "" or entity_id is None: - # logger.debug("[LOAD-IF] HOMEASSISTANT get historical values"+ - # " - No entity_id configured.") return [] - # Headers for the API request headers = { "Authorization": f"Bearer {self.access_token}", "Content-Type": "application/json", } - - # API endpoint to get the history of the entity url = f"{self.url}/api/history/period/{start_time.isoformat()}" - - # Parameters for the API request params = {"filter_entity_id": entity_id, "end_time": end_time.isoformat()} - - # Make the API request - try: - response = requests.get(url, headers=headers, params=params, timeout=10) - # Check if the request was successful - if response.status_code == 200: - historical_data = response.json() - # Extract only 'state' and 'last_updated' from the historical data - filtered_data = [ - {"state": entry["state"], "last_updated": entry["last_updated"]} - for sublist in historical_data - for entry in sublist - ] - return filtered_data - logger.error( - "[LOAD-IF] HOMEASSISTANT - Failed to retrieve" - + " historical data for '%s' - error: %s - error message: %s", - entity_id, - response.status_code, - response.text - ) - return [] - except requests.exceptions.Timeout: - logger.error( - "[LOAD-IF] HOMEASSISTANT - Request timed out" - + " while fetching historical energy data for '%s'.", - entity_id, - ) + response = self.__request_with_retries( + "get", url, params=params, headers=headers, timeout=10, item_label=entity_id + ) + if response is None: + # Do not log error here; already logged in __request_with_retries return [] - except requests.exceptions.RequestException as e: + try: + historical_data = response.json() + filtered_data = [ + { + "state": entry["state"], + "last_updated": entry["last_updated"], + "attributes": entry.get("attributes", {}), + } + for sublist in historical_data + for entry in sublist + ] + # check if the data are delivered with unit kW and convert to W + if ( + filtered_data + and "attributes" in filtered_data[0] + and "unit_of_measurement" in filtered_data[0]["attributes"] + ): + unit = filtered_data[0]["attributes"]["unit_of_measurement"] + if unit == "kW": + for entry in filtered_data: + try: + entry["state"] = float(entry["state"]) * 1000 + except ValueError: + continue + return filtered_data + except (ValueError, KeyError, TypeError) as e: logger.error( - "[LOAD-IF] HOMEASSISTANT - Request failed while fetching" - + " historical energy data for '%s' - error: %s", + "[LOAD-IF] HOMEASSISTANT - Failed to process energy data for '%s': %s", entity_id, - e, + str(e), ) return [] def __process_energy_data(self, data, debug_sensor=None): """ - Processes energy data to calculate the average energy consumption based on timestamps. + Calculate the average power (in W) from a sequence of historical sensor samples. + + The function expects `data` to be a dict with a "data" key containing a list of + timestamped samples. Each sample is a dict with at least: + - "state": numeric or numeric-string sensor value (power in W) + - "last_updated": ISO 8601 timestamp string + + Important expectations and behavior: + - The list must be time-ordered with the most recent entry first (index 0) and + older entries later (index n-1). The algorithm computes values using consecutive + pairs (current, next) from the list. + - For each consecutive pair the duration in seconds is computed from their + timestamps and the product state * duration (W * s) is accumulated. + - The returned value is an average power in watts (W). This is computed as: + average_W = (sum over intervals of state * duration) / (total duration) + and rounded to 4 decimal places. + - Entries with missing keys, non-numeric states, or states equal to "unavailable" + are skipped. Parsing errors are logged; when the source is Home Assistant a + helpful debug URL fragment is generated if possible using `debug_sensor`. + - If the total measured duration is less than one hour (3600 s), the code + extrapolates the last known state forward (up to the next hour boundary) to avoid + extremely short-sample bias. + - If no valid duration was accumulated, the function returns 0.0. + + Args: + data (dict): {"data": [ {"state": str|float, "last_updated": ISOtimestamp}, ... ]} + debug_sensor (str|None): optional sensor id used to build debug URLs when logging. + + Returns: + float: average power in watts (W), rounded to 4 decimals. Returns 0.0 if no valid data. """ total_energy = 0.0 total_duration = 0.0 @@ -217,16 +314,13 @@ def __process_energy_data(self, data, debug_sensor=None): for i in range(len(data["data"]) - 1): # check if data are available if ( - data["data"][i + 1]["state"] == "unavailable" - or data["data"][i]["state"] == "unavailable" + "state" not in data["data"][i + 1] + or "state" not in data["data"][i] + or data["data"][i + 1].get("state") == "unavailable" + or data["data"][i].get("state") == "unavailable" + or data["data"][i + 1].get("state") == "unknown" + or data["data"][i].get("state") == "unknown" ): - # if debug_name != "add_load_1": - # logger.error( - # "[LOAD-IF] state 'unavailable' in data '%s' at index %d: %s", - # debug_name if debug_name is not None else '', - # i, - # data["data"][i], - # ) continue try: current_state = float(data["data"][i]["state"]) @@ -250,6 +344,15 @@ def __process_energy_data(self, data, debug_sensor=None): + quote((current_time + timedelta(hours=2)).isoformat()) + ")" ) + logger.info( + "[LOAD-IF] Skipping invalid sensor data for '%s' at %s: state '%s' cannot be" + + " processed (%s). " + "This may indicate missing or corrupted data in the database. %s", + debug_sensor if debug_sensor is not None else "unknown sensor", + datetime.fromisoformat(data["data"][i]["last_updated"]).strftime( + "%Y-%m-%d %H:%M:%S" + ), + data["data"][i]["state"], logger.info( "[LOAD-IF] Skipping invalid sensor data for '%s' at %s: state '%s' cannot be" + " processed (%s). " @@ -466,7 +569,9 @@ def get_load_profile_for_day(self, start_time, end_time): + "&end_date=" + quote((current_time + timedelta(hours=2)).isoformat()) + " )" + + " )" ) + logger.warning( logger.warning( "[LOAD-IF] DATA ERROR load smaller than car load " + "- Energy for %s: %5.1f Wh (sum add energy %5.1f Wh - car load: %5.1f Wh) %s", @@ -477,6 +582,7 @@ def get_load_profile_for_day(self, start_time, end_time): debug_url, ) if energy == 0: + logger.debug( logger.debug( "[LOAD-IF] load = 0 ... Energy for %s: %5.1f Wh" + " (sum add energy %5.1f Wh - car load: %5.1f Wh)", @@ -486,6 +592,7 @@ def get_load_profile_for_day(self, start_time, end_time): round(car_load_energy, 1), ) + load_profile.append(energy) load_profile.append(energy) logger.debug( "[LOAD-IF] Energy for %s: %5.1f Wh (sum add energy %5.1f Wh - car load: %5.1f Wh)", @@ -579,6 +686,43 @@ def __create_load_profile_weekdays(self): else: load_profile.append(value) + # Check if load profile contains useful values (not all zeros) + if not load_profile or all(value == 0 for value in load_profile): + logger.info( + "[LOAD-IF] No historical data available from 7 and 14 days ago. " + + "This is normal for new installations - using yesterday's data as fallback. " + + "Load profiles will improve automatically as the system collects" + + " more historical data." + ) + # Get yesterday's load profile + yesterday = now.replace( + hour=0, minute=0, second=0, microsecond=0 + ) - timedelta(days=1) + yesterday_profile = self.get_load_profile_for_day( + yesterday, yesterday + timedelta(days=1) + ) + + # Double yesterday's profile to create 48 hours + if yesterday_profile and not all(value == 0 for value in yesterday_profile): + load_profile = yesterday_profile + yesterday_profile + logger.info( + "[LOAD-IF] Using yesterday's consumption pattern doubled" + + " for 48-hour forecast" + ) + else: + logger.info( + "[LOAD-IF] No recent consumption data available yet. " + + "Using built-in default profile as temporary fallback. " + + "This will automatically switch to real data as your system runs" + + " and collects sensor data." + ) + load_profile = self._get_default_profile() + logger.info( + "[LOAD-IF] Temporary default profile active -" + + " will improve with collected data" + ) + + # Check if load profile contains useful values (not all zeros) if not load_profile or all(value == 0 for value in load_profile): logger.info( @@ -653,6 +797,32 @@ def get_load_profile(self, tgt_duration, start_time=None): ) return self._get_default_profile()[:tgt_duration] + def _get_default_profile(self): + """ + Returns the default load profile that can be reused across methods. + + Returns: + list: A list of 48 default energy consumption values. + """ + return [ + if self.src == "default": + logger.info("[LOAD-IF] Using load source default") + return self._get_default_profile()[:tgt_duration] + if self.src in ("openhab", "homeassistant"): + if self.load_sensor == "" or self.load_sensor is None: + logger.error( + "[LOAD-IF] Load sensor not configured for source '%s'. Using default.", + self.src, + ) + return self._get_default_profile()[:tgt_duration] + return self.__create_load_profile_weekdays() + + logger.error( + "[LOAD-IF] Load source '%s' currently not supported. Using default.", + self.src, + ) + return self._get_default_profile()[:tgt_duration] + def _get_default_profile(self): """ Returns the default load profile that can be reused across methods. diff --git a/src/interfaces/mqtt_interface.py b/src/interfaces/mqtt_interface.py index 55a7cbcb..dc7c572e 100644 --- a/src/interfaces/mqtt_interface.py +++ b/src/interfaces/mqtt_interface.py @@ -93,35 +93,42 @@ def __init__(self, config_mqtt: Dict[str, Any], on_mqtt_command=None): "device_class": None, "icon": "mdi:state-machine", "value_template": ( - "{% if value == '-2' %}Auto" - "{% elif value == '-1' %}StartUp" - "{% elif value == '0' %}Charge from Grid" - "{% elif value == '1' %}Avoid Discharge" - "{% elif value == '2' %}Discharge Allowed" - "{% elif value == '3' %}Avoid Discharge EVCC FAST" - "{% elif value == '4' %}Avoid Discharge EVCC PV" - "{% elif value == '5' %}Avoid Discharge EVCC MIN+PV" + "{% set v = value|int %}" + "{% if v == -2 %}Auto" + "{% elif v == -1 %}StartUp" + "{% elif v == 0 %}Charge from Grid" + "{% elif v == 1 %}Avoid Discharge" + "{% elif v == 2 %}Discharge Allowed" + "{% elif v == 3 %}Avoid Discharge EVCC FAST" + "{% elif v == 4 %}Avoid Discharge EVCC PV" + "{% elif v == 5 %}Avoid Discharge EVCC MIN+PV" + "{% elif v == 6 %}Charge from Grid EVCC FAST" "{% else %}Unknown{% endif %}" ), "command_template": ( - "{% if value == 'Auto' %}-2" - "{% elif value == 'Charge from Grid' %}0" - "{% elif value == 'Avoid Discharge' %}1" - "{% elif value == 'Discharge Allowed' %}2" - # "{% elif value == 'Avoid Discharge EVCC FAST' %}3" - # "{% elif value == 'Avoid Discharge EVCC PV' %}4" - # "{% elif value == 'Avoid Discharge EVCC MIN+PV' %}5" - "{% else %}2{% endif %}" + "{% set labels = {" + "'Auto': -2, " + "'StartUp': -2, " + "'Charge from Grid': 0, " + "'Avoid Discharge': 1, " + "'Discharge Allowed': 2, " + "'Avoid Discharge EVCC FAST': -2, " + "'Avoid Discharge EVCC PV': -2, " + "'Avoid Discharge EVCC MIN+PV': -2, " + "'Charge from Grid EVCC FAST': -2" + "} %}" + "{% if value is not none and (value|int(0)|string) == (value|string) %}{{ value|int(0) }}{% elif value in labels %}{{ labels[value] }}{% else %}-2{% endif %}" ), "options": [ + "Auto", + "StartUp", "Charge from Grid", "Avoid Discharge", "Discharge Allowed", - # "Avoid Discharge EVCC FAST", - # "Avoid Discharge EVCC PV", - # "Avoid Discharge EVCC MIN+PV", - "Auto", - # "StartUp" + "Avoid Discharge EVCC FAST", + "Avoid Discharge EVCC PV", + "Avoid Discharge EVCC MIN+PV", + "Charge from Grid EVCC FAST", ], }, "control/eos_ac_charge_demand": { @@ -167,7 +174,7 @@ def __init__(self, config_mqtt: Dict[str, Any], on_mqtt_command=None): "type": "binary_sensor", "device_class": None, "value_template": "{{ 'OFF' if 'False' in value else 'ON'}}", - "icon": "mdi:state-machine", + "icon": "mdi:heat-pump-outline", }, "control/eos_homeappliance_start_hour": { "value": None, @@ -177,7 +184,7 @@ def __init__(self, config_mqtt: Dict[str, Any], on_mqtt_command=None): "unit": "hour", "type": "sensor", "device_class": None, - "icon": "mdi:state-machine", + "icon": "mdi:clock-start", }, "control/override_remain_time": { "value": None, @@ -189,7 +196,7 @@ def __init__(self, config_mqtt: Dict[str, Any], on_mqtt_command=None): "unit": None, "type": "select", "device_class": None, - "icon": "mdi:clock", + "icon": "mdi:clock-end", "options": [ "00:30", "01:00", @@ -230,7 +237,8 @@ def __init__(self, config_mqtt: Dict[str, Any], on_mqtt_command=None): "max": 10000, "step": 100, "device_class": "power", - "icon": "mdi:state-machine", + "icon": "mdi:power-plug-battery-outline", + "value_template": "{{ value|int }}", # Ensures integer display in HA }, "control/override_active": { "value": None, @@ -241,7 +249,7 @@ def __init__(self, config_mqtt: Dict[str, Any], on_mqtt_command=None): "type": "binary_sensor", "device_class": None, "value_template": "{{ 'OFF' if 'False' in value else 'ON'}}", - "icon": "mdi:state-machine", + "icon": "mdi:autorenew-off", }, "control/override_end_time": { "value": None, @@ -251,7 +259,7 @@ def __init__(self, config_mqtt: Dict[str, Any], on_mqtt_command=None): "unit": None, "type": "sensor", "device_class": "timestamp", - "icon": "mdi:clock", + "icon": "mdi:clock-end", }, "optimization/last_run": { "value": None, @@ -261,7 +269,7 @@ def __init__(self, config_mqtt: Dict[str, Any], on_mqtt_command=None): "unit": None, "type": "sensor", "device_class": "timestamp", - "icon": "mdi:clock", + "icon": "mdi:timer-check-outline", }, "optimization/next_run": { "value": None, @@ -271,7 +279,7 @@ def __init__(self, config_mqtt: Dict[str, Any], on_mqtt_command=None): "unit": None, "type": "sensor", "device_class": "timestamp", - "icon": "mdi:clock", + "icon": "mdi:timer-play-outline", }, "optimization/state": { "value": None, @@ -367,7 +375,7 @@ def __init__(self, config_mqtt: Dict[str, Any], on_mqtt_command=None): "unit": "Wh", "type": "sensor", "device_class": "energy", - "icon": "mdi:energy", + "icon": "mdi:home-battery-outline", }, "battery/dyn_max_charge_power": { "value": None, @@ -380,6 +388,38 @@ def __init__(self, config_mqtt: Dict[str, Any], on_mqtt_command=None): "icon": None, "entity_category": "diagnostic", }, + "battery/soc_min": { + "value": None, + "set_value": None, + "command_topic": "battery/soc_min/set", + "name": "Battery min SOC", + "qos": 0, + "retain": True, + "unit": "%", + "type": "number", + "min": 0, + "max": 100, + "step": 1, + "device_class": None, + "icon": "mdi:battery-minus-outline", + "entity_category": "diagnostic", + }, + "battery/soc_max": { + "value": None, + "set_value": None, + "command_topic": "battery/soc_max/set", + "name": "Battery max SOC", + "qos": 0, + "retain": True, + "unit": "%", + "type": "number", + "min": 0, + "max": 100, + "step": 1, + "device_class": None, + "icon": "mdi:battery-plus-outline", + "entity_category": "diagnostic", + }, } self.topics_publish_last = { @@ -491,42 +531,18 @@ def __on_message(self, client, userdata, msg): ) except (TypeError, ValueError) as e: logger.error("[MQTT] Error while updating publish topics: %s", e) - # call the callback if it is set and current topic is "control/overall_state" - if self.on_mqtt_command and topic == "control/overall_state": - self.__set_change_mode_callback(topic) - - def __set_change_mode_callback(self, topic): - """ - Private method to handle the change mode callback for MQTT commands. - - This method logs the received MQTT topic and associated values, then - invokes the `on_mqtt_command` callback with a dictionary containing - mode, duration, and grid charge power information. - - Args: - topic (str): The MQTT topic triggering the callback. - - Logs: - Logs the topic, remaining time, and charge power values for debugging. - """ - logger.debug( - "[MQTT] Calling on_mqtt_command callback with topic '%s' and " - + "remain time '%s' and charge power '%s'", - topic, - self.topics_publish["control/override_remain_time"]["set_value"], - self.topics_publish["control/override_charge_power"]["set_value"], - ) - self.on_mqtt_command( - { - "mode": self.topics_publish["control/overall_state"]["set_value"], - "duration": self.topics_publish["control/override_remain_time"][ - "set_value" - ], - "charge_power": self.topics_publish["control/override_charge_power"][ - "set_value" - ], + # Map topics to command keys for simplified handling + topic_command_map = { + "control/overall_state": "mode", + "control/override_remain_time": "duration", + "control/override_charge_power": "charge_power", + "battery/soc_min": "soc_min", + "battery/soc_max": "soc_max", } - ) + if self.on_mqtt_command and topic in topic_command_map: + self.on_mqtt_command( + {topic_command_map[topic]: self.topics_publish[topic]["set_value"]} + ) def __connect(self): """ @@ -667,6 +683,7 @@ def __send_mqtt_discovery_messages(self) -> None: value["device_class"], value["unit"], self.base_topic + "/" + topic, + icon=value.get("icon") and value["icon"], command_topic=value.get("command_topic") and self.base_topic + "/" + value["command_topic"], entity_category=value.get("entity_category") @@ -688,6 +705,7 @@ def __publish_mqtt_discovery_message( device_class: str, unit_of_measurement: str, state_topic: str, + icon: Optional[str] = None, command_topic: Optional[str] = None, entity_category: Optional[str] = None, min_value=None, @@ -723,6 +741,8 @@ def __publish_mqtt_discovery_message( payload["device_class"] = device_class if unit_of_measurement: payload["unit_of_measurement"] = unit_of_measurement + if icon: + payload["icon"] = icon if item_type == "number": payload["min"] = min_value payload["max"] = max_value diff --git a/src/interfaces/optimization_backends/optimization_backend_eos.py b/src/interfaces/optimization_backends/optimization_backend_eos.py new file mode 100644 index 00000000..23a49887 --- /dev/null +++ b/src/interfaces/optimization_backends/optimization_backend_eos.py @@ -0,0 +1,369 @@ +""" +This module provides the EOSBackend class for interacting with the EOS optimization server. +It includes methods for sending optimization requests, managing configuration, and handling +measurement data. +""" + +import logging +import sys +import time +import json +from datetime import datetime +import requests +import pandas as pd +import numpy as np + +logger = logging.getLogger("__main__") + + +class EOSBackend: + """ + Backend for direct EOS server optimization. + Accepts and returns EOS-format requests/responses. + """ + + def __init__(self, base_url, time_frame_base, time_zone): + self.base_url = base_url + self.time_frame_base = time_frame_base + self.time_zone = time_zone + self.last_optimization_runtimes = [0] * 5 + self.last_optimization_runtime_number = 0 + self.eos_version = ">=2025-04-09" # default + try: + self.eos_version = self._retrieve_eos_version() + if self.eos_version == "0.1.0+dev": + # check config for needed values + config_optimization = self.__get_config_path("optimization") + config_optimization_upodate_needed = False + if config_optimization.get("horizon_hours", 0) != 48: + config_optimization["horizon_hours"] = 48 + config_optimization_upodate_needed = True + if config_optimization.get("genetic", None) is None: + config_optimization["genetic"] = { + "individuals": 300, + "generations": 400, + } + config_optimization_upodate_needed = True + if config_optimization_upodate_needed: + self.__set_config_path("optimization", config_optimization) + logger.warning( + "[EOS] Detected EOS version 0.1.0+dev - config updated with " + + ": %s", + config_optimization, + ) + else: + logger.info( + "[EOS] Detected EOS version 0.1.0+dev - config optimization values OK" + ) + + config_devices = self.__get_config_path("devices/electric_vehicles") + if config_devices is None: + # if config_devices[0].get("charge_rates", None) is None: + config_devices = [{}] + config_devices[0]["charge_rates"] = sorted( + [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0] + ) + self.__set_config_path("devices/electric_vehicles", config_devices) + logger.warning( + "[EOS] Detected EOS version 0.1.0+dev - config updated with charge " + + "rates for electric vehicles" + ) + elif "charge_rates" not in config_devices[0]: + config_devices[0]["charge_rates"] = sorted( + [0.0, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0] + ) + self.__set_config_path("devices/electric_vehicles", config_devices) + logger.warning( + "[EOS] Detected EOS version 0.1.0+dev - config updated with charge " + + "rates for electric vehicles" + ) + else: + logger.info( + "[EOS] Detected EOS version 0.1.0+dev - config charge rates for " + + "electric vehicles OK" + ) + logger.info("[EOS] Configuration validation successful") + except ValueError as e: + logger.error("[EOS] EOS backend configuration error: %s", str(e)) + logger.error("[EOS] We have to exit now ...") + sys.exit(1) # Exit if configuration is invalid + + def optimize(self, eos_request, timeout=180): + """ + Send the optimize request to the EOS server. + Returns (response_json, avg_runtime) + """ + headers = {"accept": "application/json", "Content-Type": "application/json"} + request_url = ( + self.base_url + + "/optimize" + + "?start_hour=" + + str(datetime.now(self.time_zone).hour) + ) + logger.info( + "[EOS] OPTIMIZE request optimization with: %s - and with timeout: %s", + request_url, + timeout, + ) + response = None + try: + start_time = time.time() + response = requests.post( + request_url, headers=headers, json=eos_request, timeout=timeout + ) + end_time = time.time() + elapsed_time = end_time - start_time + minutes, seconds = divmod(elapsed_time, 60) + logger.info( + "[EOS] OPTIMIZE response retrieved successfully in %d min %.2f sec for current run", + int(minutes), + seconds, + ) + response.raise_for_status() + # Store runtime in circular list + if all(runtime == 0 for runtime in self.last_optimization_runtimes): + self.last_optimization_runtimes = [elapsed_time] * 5 + else: + self.last_optimization_runtimes[ + self.last_optimization_runtime_number + ] = elapsed_time + self.last_optimization_runtime_number = ( + self.last_optimization_runtime_number + 1 + ) % 5 + avg_runtime = sum(self.last_optimization_runtimes) / 5 + return response.json(), avg_runtime + except requests.exceptions.Timeout: + logger.error("[EOS] OPTIMIZE Request timed out after %s seconds", timeout) + return {"error": "Request timed out - trying again with next run"}, None + except requests.exceptions.ConnectionError as e: + logger.error( + "[EOS] OPTIMIZE Connection error - EOS server not reachable at %s " + "will try again with next cycle - error: %s", + request_url, + str(e), + ) + return { + "error": f"EOS server not reachable at {self.base_url} " + "will try again with next cycle" + }, None + except requests.exceptions.RequestException as e: + logger.error("[EOS] OPTIMIZE Request failed: %s", e) + if response is not None: + logger.error("[EOS] OPTIMIZE Response status: %s", response.status_code) + logger.debug( + "[EOS] OPTIMIZE ERROR - response of EOS is:\n%s", + response.text, + ) + logger.debug( + "[EOS] OPTIMIZE ERROR - payload for the request was:\n%s", + eos_request, + ) + return {"error": str(e)}, None + + def __get_config_path(self, path): + """ + Get a configuration value from the EOS server. + """ + # Always specify a timeout to avoid hanging indefinitely + response = requests.get(self.base_url + "/v1/config/" + path, timeout=10) + response.raise_for_status() + config_value = response.json() + return config_value + + def __set_config_path(self, path, value): + """ + Set a configuration value on the EOS server. + + Args: + path (str): The configuration path. + value (dict or list): The configuration value as a JSON-serializable + object or a list of such objects. + """ + + def convert_sets(obj): + """Recursively convert sets to lists in a dict or list.""" + if isinstance(obj, dict): + return {k: convert_sets(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [convert_sets(v) for v in obj] + elif isinstance(obj, set): + return list(obj) + else: + return obj + + headers = {"Content-Type": "application/json"} + try: + value_serializable = convert_sets(value) + response = requests.put( + self.base_url + "/v1/config/" + path, + data=json.dumps(value_serializable), + headers=headers, + timeout=10, + ) + response.raise_for_status() + except requests.exceptions.RequestException as e: + logger.error("[EOS] Failed to set config value for path '%s': %s", path, e) + + def send_measurement_to_eos(self, dataframe): + """ + Send the measurement data to the EOS server. + """ + params = { + "data": dataframe.to_json(orient="index"), + "dtype": "float64", + "tz": "UTC", + } + response = requests.put( + self.base_url + + "/v1/measurement/load-mr/series/by-name" + + "?name=Household", + params=params, + timeout=10, + ) + response.raise_for_status() + if response.status_code == 200: + logger.debug("[EOS] Measurement data sent to EOS server successfully.") + else: + logger.debug( + "[EOS] Failed to send data to EOS server. Status code: %s, Response: %s", + response.status_code, + response.text, + ) + + def save_config_to_config_file(self): + """ + Save the current configuration to the configuration file on the EOS server. + """ + response = requests.put(self.base_url + "/v1/config/file", timeout=10) + response.raise_for_status() + logger.debug("[EOS] CONFIG saved to config file successfully.") + + def update_config_from_config_file(self): + """ + Update the current configuration from the configuration file on the EOS server. + """ + try: + response = requests.post(self.base_url + "/v1/config/update", timeout=10) + response.raise_for_status() + logger.info("[EOS] CONFIG updated from config file successfully.") + except requests.exceptions.Timeout: + logger.error( + "[EOS] CONFIG Request timed out while updating config from config file." + ) + except requests.exceptions.RequestException as e: + logger.error( + "[EOS] CONFIG Request failed while updating config from config file: %s", + e, + ) + + def _retrieve_eos_version(self): + """ + Get the EOS version from the server. + Returns: str + """ + try: + response = requests.get(self.base_url + "/v1/health", timeout=10) + response.raise_for_status() + eos_version = response.json().get("status") + eos_version_real = response.json().get("version", "unknown") + if eos_version == "alive" and eos_version_real == "unknown": + eos_version = ">=2025-04-09" + else: + eos_version = eos_version_real + # raise ValueError( + # f"EOS version {eos_version_real} currently not supported!" + # ) + logger.info("[EOS] Getting EOS version: %s", eos_version) + return eos_version + except requests.exceptions.HTTPError as e: + if hasattr(e, "response") and e.response and e.response.status_code == 404: + eos_version = "<2025-04-09" + logger.info("[EOS] Getting EOS version: %s", eos_version) + return eos_version + else: + logger.error( + "[EOS] HTTP error occurred while getting EOS version - use preset version:" + + " %s : %s - Response: %s", + self.eos_version, + e, + ( + e.response.text + if hasattr(e, "response") and e.response + else "No response" + ), + ) + return self.eos_version + except requests.exceptions.ConnectTimeout: + logger.error( + "[EOS] Failed to get EOS version - use preset version: '%s' - Server not " + + "reachable: Connection to %s timed out", + self.eos_version, + self.base_url, + ) + return self.eos_version + except requests.exceptions.ConnectionError as e: + logger.error( + "[EOS] Failed to get EOS version - use preset version: '%s' - Connection error: %s", + self.eos_version, + e, + ) + return self.eos_version + except requests.exceptions.RequestException as e: + logger.error( + "[EOS] Failed to get EOS version - use preset version: '%s' - Error: %s ", + self.eos_version, + e, + ) + return self.eos_version + except json.JSONDecodeError as e: + logger.error( + "[EOS] Failed to decode EOS version - use preset version: '%s' - response: %s ", + self.eos_version, + e, + ) + return self.eos_version + + def get_eos_version(self): + """ + Get the EOS version from the server. + Returns: str + """ + return self.eos_version + + def create_dataframe(self, profile): + """ + Creates a pandas DataFrame with hourly energy values for a given profile. + Args: + profile (list of tuples): Each tuple: (month, weekday, hour, energy) + Returns: + pandas.DataFrame: DateTime index for 2025, 'Household' column. + """ + dates = pd.date_range(start="1/1/2025", end="31/12/2025", freq="H") + df = pd.DataFrame(index=dates) + df["Household"] = np.nan + for entry in profile: + month, weekday, hour, energy = entry + matching_dates = df[ + (df.index.month == month) + & (df.index.weekday == weekday) + & (df.index.hour == hour) + ].index + for date in matching_dates: + df.loc[date, "Household"] = energy + return df + + def _validate_eos_input(self, eos_request): + """ + Validate EOS-format optimization request. + Returns: (bool, list[str]) - valid, errors + """ + errors = [] + if not isinstance(eos_request, dict): + errors.append("Request must be a dictionary.") + # Add more checks as needed + # Example: check required keys + required_keys = ["ems", "pv_akku"] + for key in required_keys: + if key not in eos_request: + errors.append(f"Missing required key: {key}") + return len(errors) == 0, errors diff --git a/src/interfaces/optimization_backends/optimization_backend_evopt.py b/src/interfaces/optimization_backends/optimization_backend_evopt.py new file mode 100644 index 00000000..75d7f82f --- /dev/null +++ b/src/interfaces/optimization_backends/optimization_backend_evopt.py @@ -0,0 +1,630 @@ +""" +Module: optimization_backend_evopt +This module provides the EVOptBackend class, which acts as a backend for EVopt optimization. +It accepts EOS-format optimization requests, transforms them into the EVopt format, sends them +to the EVopt server, +and transforms the responses back into EOS-format responses. +Classes: + EVCCOptBackend: Handles the transformation, communication, and response processing for + EVopt optimization. +Typical usage example: + backend = EVCCOptBackend(base_url="http://evcc-opt-server", + time_zone=pytz.timezone("Europe/Berlin")) + eos_response, avg_runtime = backend.optimize(eos_request) +""" + +import logging +import time +import json +import os +from datetime import datetime +import requests + +logger = logging.getLogger("__main__") + + +class EVOptBackend: + """ + Backend for EVopt optimization. + Accepts EOS-format requests, transforms to EVopt format, and returns EOS-format responses. + """ + + def __init__(self, base_url, time_frame_base, time_zone): + self.base_url = base_url + self.time_frame_base = time_frame_base + self.time_zone = time_zone + self.last_optimization_runtimes = [0] * 5 + self.last_optimization_runtime_number = 0 + + def optimize(self, eos_request, timeout=180): + """ + Accepts EOS-format request, transforms to EVopt format, sends request, + transforms response back to EOS-format, and returns (response_json, avg_runtime). + """ + evcc_request, errors = self._transform_request_from_eos_to_evopt(eos_request) + if errors: + logger.error("[EVopt] Request transformation errors: %s", errors) + # Optionally, write transformed payload to json file for debugging + debug_path = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "json", + "optimize_request_evopt.json", + ) + debug_path = os.path.abspath(debug_path) + try: + with open(debug_path, "w", encoding="utf-8") as fh: + json.dump(evcc_request, fh, indent=2, ensure_ascii=False) + except OSError as e: + logger.warning("[EVopt] Could not write debug file: %s", e) + + request_url = self.base_url + "/optimize/charge-schedule" + logger.info( + "[EVopt] Request optimization with: %s - and with timeout: %s", + request_url, + timeout, + ) + headers = {"accept": "application/json", "Content-Type": "application/json"} + response = None + try: + start_time = time.time() + response = requests.post( + request_url, headers=headers, json=evcc_request, timeout=timeout + ) + end_time = time.time() + elapsed_time = end_time - start_time + minutes, seconds = divmod(elapsed_time, 60) + logger.info( + "[EVopt] Response retrieved successfully in %d min %.2f sec for current run", + int(minutes), + seconds, + ) + response.raise_for_status() + # Store runtime in circular list + if all(runtime == 0 for runtime in self.last_optimization_runtimes): + self.last_optimization_runtimes = [elapsed_time] * 5 + else: + self.last_optimization_runtimes[ + self.last_optimization_runtime_number + ] = elapsed_time + self.last_optimization_runtime_number = ( + self.last_optimization_runtime_number + 1 + ) % 5 + avg_runtime = sum(self.last_optimization_runtimes) / 5 + evcc_response = response.json() + + # Optionally, write transformed payload to json file for debugging + debug_path = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "json", + "optimize_response_evopt.json", + ) + debug_path = os.path.abspath(debug_path) + try: + with open(debug_path, "w", encoding="utf-8") as fh: + json.dump(evcc_response, fh, indent=2, ensure_ascii=False) + except OSError as e: + logger.warning("[EVopt] Could not write debug file: %s", e) + + eos_response = self._transform_response_from_evcc( + evcc_response, evcc_request + ) + return eos_response, avg_runtime + except requests.exceptions.Timeout: + logger.error("[EVopt] Request timed out after %s seconds", timeout) + return {"error": "Request timed out - trying again with next run"}, None + except requests.exceptions.ConnectionError as e: + logger.error( + "[EVopt] Connection error - server not reachable at %s " + "will try again with next cycle - error: %s", + request_url, + str(e), + ) + return { + "error": f"EVopt server not reachable at {self.base_url} " + "will try again with next cycle" + }, None + except requests.exceptions.RequestException as e: + logger.error("[EVopt] Request failed: %s", e) + if response is not None: + logger.error("[EVopt] Response status: %s", response.status_code) + logger.debug( + "[EVopt] ERROR - response of server is:\n%s", + response.text, + ) + logger.debug( + "[EVopt] ERROR - payload for the request was:\n%s", + evcc_request, + ) + return {"error": str(e)}, None + + def _transform_request_from_eos_to_evopt(self, eos_request): + """ + Translate EOS request -> EVCC request. + Returns (evopt: dict, external_errors: list[str]) + """ + eos_request = eos_request or {} + errors = [] + + ems = eos_request.get("ems", {}) or {} + pv_series = ems.get("pv_prognose_wh", []) or [] + price_series = ems.get("strompreis_euro_pro_wh", []) or [] + feed_series = ems.get("einspeiseverguetung_euro_pro_wh", []) or [] + load_series = ems.get("gesamtlast", []) or [] + + current_hour = datetime.now(self.time_zone).hour + pv_series = ( + pv_series[current_hour:] if len(pv_series) > current_hour else pv_series + ) + price_series = ( + price_series[current_hour:] + if len(price_series) > current_hour + else price_series + ) + feed_series = ( + feed_series[current_hour:] + if len(feed_series) > current_hour + else feed_series + ) + load_series = ( + load_series[current_hour:] + if len(load_series) > current_hour + else load_series + ) + + lengths = [ + len(s) + for s in (pv_series, price_series, feed_series, load_series) + if len(s) > 0 + ] + n = min(lengths) if lengths else 1 + + def normalize(arr): + return [float(x) for x in arr[:n]] if arr else [0.0] * n + + pv_ts = normalize(pv_series) + price_ts = normalize(price_series) + feed_ts = normalize(feed_series) + load_ts = normalize(load_series) + + pv_akku = eos_request.get("pv_akku") or {} + batt_capacity_wh = float(pv_akku.get("capacity_wh", 0)) + batt_initial_pct = float(pv_akku.get("initial_soc_percentage", 0)) + batt_min_pct = float(pv_akku.get("min_soc_percentage", 0)) + batt_max_pct = float(pv_akku.get("max_soc_percentage", 100)) + batt_c_max = float(pv_akku.get("max_charge_power_w", 0)) + batt_eta_c = float(pv_akku.get("charging_efficiency", 0.95)) + batt_eta_d = float(pv_akku.get("discharging_efficiency", 0.95)) + + s_min = batt_capacity_wh * (batt_min_pct / 100.0) + s_max = batt_capacity_wh * (batt_max_pct / 100.0) + s_initial = batt_capacity_wh * (batt_initial_pct / 100.0) + + batteries = [] + if batt_capacity_wh > 0: + batteries.append( + { + "device_id": pv_akku.get("device_id", "akku1"), + "charge_from_grid": True, + "discharge_to_grid": True, + "s_min": s_min, + "s_max": s_max, + "s_initial": s_initial, + "p_demand": [0.0] * n, + # "s_goal": [s_initial] * n, + "s_goal": [0.0] * n, + "c_min": 0.0, + "c_max": batt_c_max, + "d_max": batt_c_max, + "p_a": 0.0, + } + ) + + p_max_imp = 10000 + p_max_exp = 10000 + + # Compute dt series based on time_frame_base + # Each entry corresponds to the time frame in seconds + # first entry may be shorter to align with time_frame_base + now = datetime.now(self.time_zone) + seconds_since_midnight = now.hour * 3600 + now.minute * 60 + now.second + dt_first_entry = self.time_frame_base - ( + seconds_since_midnight % self.time_frame_base + ) + dt_series = [dt_first_entry] + [self.time_frame_base] * (n - 1) + + evopt = { + "strategy": { + "charging_strategy": "charge_before_export", + "discharging_strategy": "discharge_before_import", + }, + "grid": { + "p_max_imp": p_max_imp, + "p_max_exp": p_max_exp, + "prc_p_imp_exc": 0, + }, + "batteries": batteries, + "time_series": { + "dt": dt_series, + "gt": [float(x) for x in load_ts], + "ft": [float(x) for x in pv_ts], + "p_N": [float(x) for x in price_ts], + "p_E": [float(x) for x in feed_ts], + }, + "eta_c": batt_eta_c if batt_capacity_wh > 0 else 0.95, + "eta_d": batt_eta_d if batt_capacity_wh > 0 else 0.95, + } + + return evopt, errors + + def _transform_response_from_evcc(self, evcc_resp, evopt=None): + """ + Translate EVoptimizer response -> EOS-style optimize response. + + Produces a fuller EOS-shaped response using the sample `src/json/optimize_response.json` + as guidance. The mapping is conservative and uses available EVCC fields: + + - ac_charge, dc_charge, discharge_allowed, start_solution + - result.* arrays: Last_Wh_pro_Stunde, EAuto_SoC_pro_Stunde, + Einnahmen_Euro_pro_Stunde, Kosten_Euro_pro_Stunde, Netzbezug_Wh_pro_Stunde, + Netzeinspeisung_Wh_pro_Stunde, Verluste_Pro_Stunde, akku_soc_pro_stunde, + Electricity_price + - numeric summaries: Gesamt_Verluste, Gesamtbilanz_Euro, Gesamteinnahmen_Euro, + Gesamtkosten_Euro + - eauto_obj, washingstart, timestamp + + evcc_resp: dict (raw EVCC JSON) or {"response": {...}}. + evopt: optional EVCC request dict (used to read p_N, p_E, eta_c, eta_d, + battery s_max/c_max). + """ + # defensive guard + if not isinstance(evcc_resp, dict): + logger.debug( + "[EOS] EVCC transform response - input not a dict, returning empty dict" + ) + return {} + + # EVCC might wrap actual payload under "response" + resp = evcc_resp.get("response", evcc_resp) + + # Set total hours and slice for future + current_hour = datetime.now(self.time_zone).hour + n_total = 48 # Total hours from midnight today to midnight tomorrow + n_future = n_total - current_hour # Hours from now to end of tomorrow + n = n_future # Override n to focus on future horizon + + # # determine horizon length n + # n = 0 + # if isinstance(resp.get("grid_import"), list): + # n = len(resp.get("grid_import")) + # elif isinstance(resp.get("grid_export"), list): + # n = len(resp.get("grid_export")) + # else: + # # try batteries[*].charging_power + # b_list = resp.get("batteries") or [] + # if isinstance(b_list, list) and len(b_list) > 0: + # b0 = b_list[0] + # if isinstance(b0.get("charging_power"), list): + # n = len(b0.get("charging_power")) + # if n == 0: + # n = 24 + + # primary battery arrays (first battery) + batteries_resp = resp.get("batteries") or [] + first_batt = batteries_resp[0] if batteries_resp else {} + charging_power = list(first_batt.get("charging_power") or [0.0] * n)[:n] + discharging_power = list(first_batt.get("discharging_power") or [0.0] * n)[:n] + soc_wh = list(first_batt.get("state_of_charge") or [])[:n] + + # grid arrays + grid_import = list(resp.get("grid_import") or [0.0] * n)[:n] + grid_export = list(resp.get("grid_export") or [0.0] * n)[:n] + + # harvest pricing from evopt when available (per-Wh units) + p_n = None + p_e = None + electricity_price = [None] * n + if isinstance(evopt, dict): + ts = evopt.get("time_series", {}) or {} + p_n = ts.get("p_N") + p_e = ts.get("p_E") + # if p_N/p_E are lists, normalize length + if isinstance(p_n, list): + p_n = ( + [float(x) for x in p_n[:n]] + + [float(p_n[-1])] * max(0, n - len(p_n)) + if p_n + else None + ) + if isinstance(p_e, list): + p_e = ( + [float(x) for x in p_e[:n]] + + [float(p_e[-1])] * max(0, n - len(p_e)) + if p_e + else None + ) + if isinstance(p_n, list): + electricity_price = [float(x) for x in p_n[:n]] + elif isinstance(p_n, (int, float)): + electricity_price = [float(p_n)] * n + + # fallback price arrays if missing + if not any(isinstance(x, (int, float)) for x in electricity_price): + electricity_price = [0.0] * n + if p_n is None: + p_n = electricity_price + if p_e is None: + p_e = [0.0] * n + + # battery parameters from request if present (s_max in Wh, eta_c, eta_d) + s_max_req = None + eta_c = None + eta_d = None + if isinstance(evopt, dict): + breq = evopt.get("batteries") + if isinstance(breq, list) and len(breq) > 0: + b0r = breq[0] + try: + s_max_req = float(b0r.get("s_max", 0.0)) + except (ValueError, TypeError): + s_max_req = None + try: + eta_c = float(evopt.get("eta_c", b0r.get("eta_c", 0.95) or 0.95)) + except (ValueError, TypeError): + eta_c = 0.95 + try: + eta_d = float(evopt.get("eta_d", b0r.get("eta_d", 0.95) or 0.95)) + except (ValueError, TypeError): + eta_d = 0.95 + # Set defaults + eta_c = eta_c if eta_c is not None else 0.95 + eta_d = eta_d if eta_d is not None else 0.95 + s_max_val = s_max_req if s_max_req not in (None, 0) else None + + # compute ac_charge fraction: charging_power normalized to c_max from request + # or observed max + c_max = None + d_max = None + if isinstance(evopt, dict): + breq = evopt.get("batteries") + if isinstance(breq, list) and len(breq) > 0: + try: + c_max = float(breq[0].get("c_max", 0.0)) + except (ValueError, TypeError): + c_max = None + try: + d_max = float(breq[0].get("d_max", 0.0)) + except (ValueError, TypeError): + d_max = None + # fallback observed maxima + try: + if not c_max: + observed_max_ch = ( + max([float(x) for x in charging_power]) if charging_power else 0.0 + ) + c_max = observed_max_ch if observed_max_ch > 0 else 1.0 + if not d_max: + observed_max_dch = ( + max([float(x) for x in discharging_power]) + if discharging_power + else 0.0 + ) + d_max = observed_max_dch if observed_max_dch > 0 else 1.0 + except (ValueError, TypeError): + c_max = c_max or 1.0 + d_max = d_max or 1.0 + + ac_charge = [] + for i, v in enumerate(charging_power): + # Use grid import if charging_power exceeds grid_import + charge_from_grid = min(float(v), float(grid_import[i])) + try: + frac = charge_from_grid / float(c_max) if float(c_max) > 0 else 0.0 + except (ValueError, TypeError): + frac = 0.0 + if frac != frac: + frac = 0.0 + ac_charge.append(max(0.0, min(1.0, frac))) + + # Adjust ac_charge: set to 0 if no grid import (PV-only charging) + for i in range(n): + if grid_import[i] <= 0: + ac_charge[i] = 0.0 + + # dc_charge: mark 1.0 if charging_power > 0 (conservative) + dc_charge = [1.0 if float(v) > 0.0 else 0.0 for v in charging_power] + + # discharge_allowed: 1 if discharging_power > tiny epsilon + discharge_allowed = [1 if float(v) > 1e-9 else 0 for v in discharging_power] + + # start_solution: prefer resp['start_solution'] if present, else try + # eauto_obj.charge_array -> ints, otherwise zeros + start_solution = None + if isinstance(resp.get("start_solution"), list): + # coerce to numbers + start_solution = [ + float(x) if isinstance(x, (int, float)) else 0 + for x in resp.get("start_solution")[:n] + ] + else: + eauto_obj = resp.get("eauto_obj") or evcc_resp.get("eauto_obj") + if isinstance(eauto_obj, dict) and isinstance( + eauto_obj.get("charge_array"), list + ): + # map boolean/float charge_array to integers (placeholder) + start_solution = [ + int(1 if float(x) > 0 else 0) + for x in eauto_obj.get("charge_array")[:n] + ] + if start_solution is None: + start_solution = [0] * n + + # washingstart if present + washingstart = resp.get("washingstart") + + # compute per-hour costs and revenues in Euro (using €/Wh units from p_N/p_E) + kosten_per_hour = [] + einnahmen_per_hour = [] + for i in range(n): + gi = float(grid_import[i]) if i < len(grid_import) else 0.0 + ge = float(grid_export[i]) if i < len(grid_export) else 0.0 + pr = ( + float(p_n[i]) + if isinstance(p_n, list) and i < len(p_n) + else float(p_n[i]) if isinstance(p_n, list) and len(p_n) > 0 else 0.0 + ) + pe = ( + float(p_e[i]) + if isinstance(p_e, list) and i < len(p_e) + else (float(p_e[i]) if isinstance(p_e, list) and len(p_e) > 0 else 0.0) + ) + # if p_N/p_E are scalars (should be lists), handle above; fallback zero if missing + if isinstance(p_n, (int, float)): + pr = float(p_n) + if isinstance(p_e, (int, float)): + pe = float(p_e) + + kosten = gi * pr + einnahmen = ge * pe + kosten_per_hour.append(kosten) + einnahmen_per_hour.append(einnahmen) + + # estimate per-hour battery losses: charging_loss + discharging_loss + verluste_per_hour = [] + for i in range(n): + ch = float(charging_power[i]) if i < len(charging_power) else 0.0 + dch = float(discharging_power[i]) if i < len(discharging_power) else 0.0 + loss = ch * (1.0 - eta_c) + dch * (1.0 - eta_d) + verluste_per_hour.append(loss) + + # Akku SoC percent per hour (if soc_wh available): convert to percent using s_max_req + # or inferred max + akku_soc_pct = [] + if soc_wh: + # determine s_max reference: use s_max_req if provided, otherwise attempt to + # infer from soc_wh max + ref = s_max_val + if not ref: + try: + ref = max([float(x) for x in soc_wh]) if soc_wh else None + except (ValueError, TypeError): + ref = None + for v in soc_wh: + try: + if ref and ref > 0: + pct = float(v) / float(ref) * 100.0 + else: + pct = float(v) + except (ValueError, TypeError): + pct = 0.0 + akku_soc_pct.append(pct) + else: + akku_soc_pct = [] + + # totals + gesamt_kosten = sum(kosten_per_hour) if kosten_per_hour else 0.0 + gesamt_einnahmen = sum(einnahmen_per_hour) if einnahmen_per_hour else 0.0 + gesamt_verluste = sum(verluste_per_hour) if verluste_per_hour else 0.0 + gesamt_bilanz = gesamt_einnahmen - gesamt_kosten + + # build result dict like optimize_response.json + result = {} + # Prefer household load ('gt') from the EVCC request if available, + # otherwise fall back to EVCC response grid_import (parity with previous behavior). + last_wh = None + if isinstance(evopt, dict): + ts = evopt.get("time_series", {}) or {} + gt = ts.get("gt") + if isinstance(gt, list) and len(gt) > 0: + # normalize/trim/pad gt to length n (similar to other normalizations) + if len(gt) >= n: + last_wh = [float(x) for x in gt[:n]] + else: + last_val = float(gt[-1]) + last_wh = [float(x) for x in gt] + [last_val] * (n - len(gt)) + # fallback to grid_import if gt not present or invalid + if last_wh is None: + last_wh = [float(x) for x in grid_import[:n]] + + result["Last_Wh_pro_Stunde"] = last_wh + + if akku_soc_pct: + # EAuto_SoC_pro_Stunde - fallback to eauto object SOC or same as akku + # percent if appropriate + result["EAuto_SoC_pro_Stunde"] = ( + [ + float(x) + for x in ( + evcc_resp.get("eauto_obj", {}).get("soc_wh") + or ( + [] + if not isinstance(evcc_resp.get("eauto_obj", {}), dict) + else [] + ) + )[:n] + ] + if evcc_resp.get("eauto_obj") + else [] + ) + # Einnahmen & Kosten per hour + result["Einnahmen_Euro_pro_Stunde"] = [float(x) for x in einnahmen_per_hour] + result["Kosten_Euro_pro_Stunde"] = [float(x) for x in kosten_per_hour] + result["Gesamt_Verluste"] = float(gesamt_verluste) + result["Gesamtbilanz_Euro"] = float(gesamt_bilanz) + result["Gesamteinnahmen_Euro"] = float(gesamt_einnahmen) + result["Gesamtkosten_Euro"] = float(gesamt_kosten) + # Home appliance placeholder (zeros) + result["Home_appliance_wh_per_hour"] = [0.0] * n + result["Netzbezug_Wh_pro_Stunde"] = [float(x) for x in grid_import[:n]] + result["Netzeinspeisung_Wh_pro_Stunde"] = [float(x) for x in grid_export[:n]] + result["Verluste_Pro_Stunde"] = [float(x) for x in verluste_per_hour] + if akku_soc_pct: + result["akku_soc_pro_stunde"] = [float(x) for x in akku_soc_pct[:n]] + # Electricity price array + result["Electricity_price"] = [float(x) for x in electricity_price[:n]] + + # Pad past hours with zeros for control arrays + pad_past = [0.0] * current_hour + + eos_resp = { + "ac_charge": pad_past + [float(x) for x in ac_charge], + "dc_charge": pad_past + [float(x) for x in dc_charge], + "discharge_allowed": pad_past + [int(x) for x in discharge_allowed], + "eautocharge_hours_float": None, + "result": result, # result arrays remain unpadded (start from current time) + } + + # attach eauto_obj if present in resp + if "eauto_obj" in resp: + eos_resp["eauto_obj"] = resp.get("eauto_obj") + + # map start_solution and washingstart if present + eos_resp["start_solution"] = pad_past + start_solution + if washingstart is not None: + eos_resp["washingstart"] = pad_past + washingstart + + # timestamp + try: + eos_resp["timestamp"] = datetime.now(self.time_zone).isoformat() + except (ValueError, TypeError): + eos_resp["timestamp"] = datetime.now().isoformat() + + return eos_resp + + def _validate_evcc_request(self, evopt): + """ + Validate EVopt-format optimization request. + Returns: (bool, list[str]) - valid, errors + """ + errors = [] + if not isinstance(evopt, dict): + errors.append("EVCC request must be a dictionary.") + # Example: check required keys + required_keys = ["strategy", "grid", "batteries", "time_series"] + for key in required_keys: + if key not in evopt: + errors.append(f"Missing required key: {key}") + return len(errors) == 0, errors diff --git a/src/interfaces/optimization_interface.py b/src/interfaces/optimization_interface.py new file mode 100644 index 00000000..18f0e28a --- /dev/null +++ b/src/interfaces/optimization_interface.py @@ -0,0 +1,305 @@ +""" +optimization_interface.py +This module provides the OptimizationInterface class, which serves as the main abstraction layer +for interacting with different optimization backends. It accepts and returns requests and responses +in the EOS format, handles backend selection, and delegates transformation logic to the selected +backend. The interface also manages control data, home appliance scheduling, and calculates the next +optimal run time for the optimization process. +Classes: + OptimizationInterface: Abstraction for optimization backends supporting EOS-format requests and + responses. +Usage: + Instantiate OptimizationInterface with configuration and timezone, then use its methods to + perform optimization, retrieve control data, and manage scheduling. +Example: + interface = OptimizationInterface(config, timezone) + response, avg_runtime = interface.optimize(eos_request) +""" + +import logging +from datetime import datetime, timedelta +from .optimization_backends.optimization_backend_eos import EOSBackend +from .optimization_backends.optimization_backend_evopt import EVOptBackend + +logger = logging.getLogger("__main__") + + +class OptimizationInterface: + """ + Main abstraction for optimization backends. + Accepts and returns EOS-format requests/responses. + Handles backend selection and delegates all transformation logic to the backend. + """ + + def __init__(self, config, time_frame_base, timezone): + self.eos_source = config.get("source", "eos_server") + self.base_url = ( + f"http://{config.get('server', '192.168.1.1')}:{config.get('port', 8503)}" + ) + self.time_frame_base = time_frame_base + self.time_zone = timezone + + if self.eos_source == "evopt": + self.backend = EVOptBackend( + self.base_url, self.time_frame_base, self.time_zone + ) + self.backend_type = "evopt" + logger.info("[OPT] Using EVopt backend") + elif self.eos_source == "eos_server": + self.backend = EOSBackend( + self.base_url, self.time_frame_base, self.time_zone + ) + self.backend_type = "eos_server" + logger.info("[OPT] Using EOS Server backend") + else: + raise ValueError(f"Unknown backend source: {self.eos_source}") + + self.last_start_solution = None + self.home_appliance_released = False + self.home_appliance_start_hour = None + self.last_control_data = [ + { + "ac_charge_demand": 0, + "dc_charge_demand": 0, + "discharge_allowed": False, + "error": 0, + "hour": -1, + }, + { + "ac_charge_demand": 0, + "dc_charge_demand": 0, + "discharge_allowed": False, + "error": 0, + "hour": -1, + }, + ] + + def optimize(self, eos_request, timeout=180): + """ + Main entry point for optimization. + Accepts EOS-format request, returns EOS-format response. + """ + eos_response, avg_runtime = self.backend.optimize(eos_request, timeout) + return eos_response, avg_runtime + + def examine_response_to_control_data(self, optimized_response_in): + """ + Examines the optimized response data for control parameters. + Returns tuple: (ac_charge, dc_charge, discharge_allowed, response_error) + """ + current_hour = datetime.now(self.time_zone).hour + ac_charge_demand_relative = None + dc_charge_demand_relative = None + discharge_allowed = None + response_error = False + + if "ac_charge" in optimized_response_in: + ac_charge_demand_relative = optimized_response_in["ac_charge"] + self.last_control_data[0]["ac_charge_demand"] = ac_charge_demand_relative[ + current_hour + ] + self.last_control_data[1]["ac_charge_demand"] = ac_charge_demand_relative[ + current_hour + 1 if current_hour < 23 else 0 + ] + ac_charge_demand_relative = ac_charge_demand_relative[current_hour] + logger.debug( + "[OPT] AC charge demand for current hour %s:00 -> %s %%", + current_hour, + ac_charge_demand_relative * 100, + ) + if "dc_charge" in optimized_response_in: + dc_charge_demand_relative = optimized_response_in["dc_charge"] + self.last_control_data[0]["dc_charge_demand"] = dc_charge_demand_relative[ + current_hour + ] + self.last_control_data[1]["dc_charge_demand"] = dc_charge_demand_relative[ + current_hour + 1 if current_hour < 23 else 0 + ] + dc_charge_demand_relative = dc_charge_demand_relative[current_hour] + logger.debug( + "[OPT] DC charge demand for current hour %s:00 -> %s %%", + current_hour, + dc_charge_demand_relative * 100, + ) + if "discharge_allowed" in optimized_response_in: + discharge_allowed = optimized_response_in["discharge_allowed"] + self.last_control_data[0]["discharge_allowed"] = discharge_allowed[ + current_hour + ] + self.last_control_data[1]["discharge_allowed"] = discharge_allowed[ + current_hour + 1 if current_hour < 23 else 0 + ] + discharge_allowed = bool(discharge_allowed[current_hour]) + logger.debug( + "[OPT] Discharge allowed for current hour %s:00 %s", + current_hour, + discharge_allowed, + ) + + if ( + "start_solution" in optimized_response_in + and len(optimized_response_in["start_solution"]) > 1 + ): + self.set_last_start_solution(optimized_response_in["start_solution"]) + logger.debug( + "[OPT] Start solution for current hour %s:00 %s", + current_hour, + self.get_last_start_solution(), + ) + else: + logger.error("[OPT] No control data in optimized response") + response_error = True + + self.last_control_data[0]["error"] = int(response_error) + self.last_control_data[1]["error"] = int(response_error) + self.last_control_data[0]["hour"] = current_hour + self.last_control_data[1]["hour"] = current_hour + 1 if current_hour < 23 else 0 + + if "washingstart" in optimized_response_in: + self.home_appliance_start_hour = optimized_response_in["washingstart"] + self.home_appliance_released = ( + self.home_appliance_start_hour == current_hour + ) + logger.debug( + "[OPT] Home appliance - current hour %s:00 - start hour %s - is Released: %s", + current_hour, + self.home_appliance_start_hour, + self.home_appliance_released, + ) + + return ( + ac_charge_demand_relative, + dc_charge_demand_relative, + discharge_allowed, + response_error, + ) + + def set_last_start_solution(self, last_start_solution): + """ + Sets the last start solution for the optimization process. + + Args: + last_start_solution: The solution to be stored as the last start solution. + """ + self.last_start_solution = last_start_solution + + def get_last_start_solution(self): + """ + Returns the last start solution used in the optimization process. + + Returns: + Any: The last start solution stored in the instance. + """ + return self.last_start_solution + + def get_last_control_data(self): + """ + Retrieve the most recent control data. + + Returns: + Any: The last control data stored in the instance. + """ + return self.last_control_data + + def get_home_appliance_released(self): + """ + Returns the value of the home_appliance_released attribute. + + Returns: + Any: The current value of home_appliance_released. + """ + return self.home_appliance_released + + def get_home_appliance_start_hour(self): + """ + Returns the start hour for the home appliance. + + Returns: + int: The hour at which the home appliance is scheduled to start. + """ + return self.home_appliance_start_hour + + def calculate_next_run_time(self, current_time, avg_runtime, update_interval): + """ + Calculate the next run time prioritizing quarter-hour alignment with improved gap filling. + """ + # Calculate minimum time between runs + min_gap_seconds = max((update_interval + avg_runtime) * 0.7, 30) + + # Find next quarter-hour from current time + next_quarter = current_time.replace(second=0, microsecond=0) + current_minute = next_quarter.minute + + minutes_past_quarter = current_minute % 15 + if minutes_past_quarter == 0 and current_time.second > 0: + minutes_to_add = 15 + elif minutes_past_quarter == 0: + minutes_to_add = 15 + else: + minutes_to_add = 15 - minutes_past_quarter + + next_quarter += timedelta(minutes=minutes_to_add) + + quarter_aligned_start = next_quarter - timedelta(seconds=avg_runtime) + + # **BUG FIX**: Check if quarter_aligned_start is in the past + if quarter_aligned_start <= current_time: + # Move to the next quarter-hour + next_quarter += timedelta(minutes=15) + quarter_aligned_start = next_quarter - timedelta(seconds=avg_runtime) + logger.debug( + "[OPTIMIZATION] Quarter start was in past, moved to next: %s", + next_quarter.strftime("%H:%M:%S"), + ) + + time_until_quarter_start = ( + quarter_aligned_start - current_time + ).total_seconds() + + # Debug logging + logger.debug( + "[OPTIMIZATION] Debug: current=%s, next_quarter=%s, quarter_start=%s, time_until=%.1fs", + current_time.strftime("%H:%M:%S"), + next_quarter.strftime("%H:%M:%S"), + quarter_aligned_start.strftime("%H:%M:%S"), + time_until_quarter_start, + ) + + # More aggressive gap-filling: if we have at least 2x the update interval, + # try a gap-fill run + if ( + time_until_quarter_start >= (2 * update_interval) + and time_until_quarter_start >= min_gap_seconds + ): + normal_next_start = current_time + timedelta(seconds=update_interval) + logger.info( + "[OPTIMIZATION] Gap-fill run: start %s (quarter-aligned run follows at %s)", + normal_next_start.strftime("%H:%M:%S"), + next_quarter.strftime("%H:%M:%S"), + ) + return normal_next_start + + # Otherwise, use quarter-aligned timing + absolute_min_seconds = max(avg_runtime * 0.5, 30) + if time_until_quarter_start < absolute_min_seconds: + next_quarter += timedelta(minutes=15) + quarter_aligned_start = next_quarter - timedelta(seconds=avg_runtime) + logger.debug( + "[OPTIMIZATION] Quarter too close, moved to next: %s", + next_quarter.strftime("%H:%M:%S"), + ) + + logger.info( + "[OPTIMIZATION] Quarter-hour aligned run: start %s, finish at %s", + quarter_aligned_start.strftime("%H:%M:%S"), + next_quarter.strftime("%H:%M:%S"), + ) + return quarter_aligned_start + + def get_eos_version(self): + """ + Returns the EOS version from the backend if available. + """ + if hasattr(self.backend, "get_eos_version"): + return self.backend.get_eos_version() + return None diff --git a/src/interfaces/price_interface.py b/src/interfaces/price_interface.py index b2fb042e..a3350944 100644 --- a/src/interfaces/price_interface.py +++ b/src/interfaces/price_interface.py @@ -6,6 +6,7 @@ - Akkudoktor API (default) - Tibber API - SmartEnergy AT API + - Stromligning.dk API - Fixed 24-hour price array Features: @@ -33,16 +34,17 @@ import json import logging import threading +import threading import requests - logger = logging.getLogger("__main__") logger.info("[PRICE-IF] loading module ") AKKUDOKTOR_API_PRICES = "https://api.akkudoktor.net/prices" TIBBER_API = "https://api.tibber.com/v1-beta/gql" SMARTENERGY_API = "https://apis.smartenergy.at/market/v1/price" +STROMLIGNING_API_BASE = "https://stromligning.dk/api/prices?lean=true" class PriceInterface: @@ -52,7 +54,7 @@ class PriceInterface: Attributes: src (str): Source of the price data - (e.g., 'tibber', 'default', 'smartenergy_at', 'fixed_24h'). + (e.g., 'tibber', 'stromligning', 'smartenergy_at', 'fixed_24h', 'default'). access_token (str): Access token for authenticating with the price source. fixed_24h_array (list): Optional fixed 24-hour price array. feed_in_tariff_price (float): Feed-in tariff price in cents per kWh. @@ -80,6 +82,8 @@ class PriceInterface: Fetches prices from the Tibber API. __retrieve_prices_from_smartenergy_at(tgt_duration, start_time=None): Fetches prices from the SmartEnergy AT API. + __retrieve_prices_from_stromligning(tgt_duration, start_time=None): + Fetches prices from the Stromligning.dk API. __retrieve_prices_from_fixed24h_array(tgt_duration, start_time=None): Returns prices from a fixed 24-hour array. """ @@ -91,6 +95,7 @@ def __init__( ): self.src = config["source"] self.access_token = config.get("token", "") + self._stromligning_url = None self.fixed_price_adder_ct = config.get("fixed_price_adder_ct", 0.0) self.relative_price_multiplier = config.get("relative_price_multiplier", 0.0) self.fixed_24h_array = config.get("fixed_24h_array", False) @@ -108,6 +113,7 @@ def __init__( self.current_prices_direct = [] # without tax self.current_feedin = [] self.default_prices = [0.0001] * 48 # if external data are not available + self.price_currency = self.__determine_price_currency() # Add retry mechanism attributes self.last_successful_prices = [] @@ -165,9 +171,14 @@ def __update_prices_loop(self): """ # Initial update try: - self.update_prices(48, datetime.now(self.time_zone).replace(hour=0, minute=0, second=0, microsecond=0)) # Get 48 hours of price data + self.update_prices( + 48, + datetime.now(self.time_zone).replace( + hour=0, minute=0, second=0, microsecond=0 + ), + ) # Get 48 hours of price data logger.info("[PRICE-IF] Initial price update completed") - except Exception as e: + except RuntimeError as e: logger.error("[PRICE-IF] Error during initial price update: %s", e) while not self._stop_event.is_set(): @@ -177,7 +188,12 @@ def __update_prices_loop(self): break # Stop event was set # Perform price update - self.update_prices(48, datetime.now(self.time_zone).replace(hour=0, minute=0, second=0, microsecond=0)) # Get 48 hours of price data + self.update_prices( + 48, + datetime.now(self.time_zone).replace( + hour=0, minute=0, second=0, microsecond=0 + ), + ) # Get 48 hours of price data logger.debug("[PRICE-IF] Periodic price update completed") except Exception as e: @@ -212,6 +228,65 @@ def __check_config(self): "[PRICE-IF] Access token is required for Tibber source but not provided." + " Usiung default price source." ) + if self.src == "stromligning": + try: + ( + supplier_id, + product_id, + customer_group_id, + ) = self._parse_stromligning_token(self.access_token) + except ValueError as exc: + self.src = "default" + self._stromligning_url = None + logger.error( + "[PRICE-IF] Invalid Stromligning token: %s. Falling back to default prices.", + exc, + ) + else: + query_parts = [ + f"productId={product_id}", + f"supplierId={supplier_id}", + ] + if customer_group_id: + query_parts.append(f"customerGroupId={customer_group_id}") + self._stromligning_url = ( + f"{STROMLIGNING_API_BASE}&{'&'.join(query_parts)}" + ) + else: + self._stromligning_url = None + + @staticmethod + def _parse_stromligning_token(token): + """ + Parses the Stromligning token into its components. + + Args: + token (str): The Stromligning token in the format + 'supplierId/productId' or 'supplierId/productId/groupId'. + + Returns: + tuple: A tuple containing supplierId, productId, and optionally customerGroupId. + + Raises: + ValueError: If the token is missing, not a string, or not in the expected format. + """ + if not token or not isinstance(token, str): + raise ValueError("token must be provided for Stromligning.") + + parts = [segment.strip() for segment in token.strip().split("/")] + if any(part == "" for part in parts): + raise ValueError( + "token segments must be non-empty when using Stromligning." + ) + + if len(parts) not in (2, 3): + raise ValueError( + "token must contain two or three segments separated by '/'." + ) + + supplier_id, product_id = parts[0], parts[1] + customer_group_id = parts[2] if len(parts) == 3 else None + return supplier_id, product_id, customer_group_id def update_prices(self, tgt_duration, start_time=None): """ @@ -230,6 +305,10 @@ def update_prices(self, tgt_duration, start_time=None): Logs: Logs a debug message indicating that prices have been updated. """ + if start_time is None: + start_time = datetime.now(self.time_zone).replace( + minute=0, second=0, microsecond=0 + ) if start_time is None: start_time = datetime.now(self.time_zone).replace( minute=0, second=0, microsecond=0 @@ -241,6 +320,11 @@ def update_prices(self, tgt_duration, start_time=None): tgt_duration, start_time.strftime("%Y-%m-%d %H:%M"), ) + logger.debug( + "[PRICE-IF] Prices updated for %d hours starting from %s", + tgt_duration, + start_time.strftime("%Y-%m-%d %H:%M"), + ) def get_current_prices(self): """ @@ -270,6 +354,15 @@ def get_current_feedin_prices(self): # ) return self.current_feedin + def get_price_currency(self): + """ + Return the currency identifier for the currently configured price source. + + Returns: + str: ISO 4217 currency code (e.g. 'EUR', 'DKK'). + """ + return self.price_currency + def __create_feedin_prices(self): """ Creates feed-in prices based on the current prices. @@ -325,6 +418,8 @@ def __retrieve_prices(self, tgt_duration, start_time=None): prices = self.__retrieve_prices_from_smartenergy_at( tgt_duration, start_time ) + elif self.src == "stromligning": + prices = self.__retrieve_prices_from_stromligning(tgt_duration, start_time) elif self.src == "fixed_24h": prices = self.__retrieve_prices_from_fixed24h_array( tgt_duration, start_time @@ -384,9 +479,73 @@ def __retrieve_prices(self, tgt_duration, start_time=None): self.last_successful_prices = prices.copy() self.last_successful_prices_direct = self.current_prices_direct.copy() logger.debug("[PRICE-IF] Prices retrieved successfully. Stored as backup.") + self.consecutive_failures += 1 + + if ( + self.consecutive_failures <= self.max_failures + and len(self.last_successful_prices) > 0 # Changed condition + ): + logger.warning( + "[PRICE-IF] No prices retrieved (failure %d/%d). Using last successful prices.", + self.consecutive_failures, + self.max_failures, + ) + prices = self.last_successful_prices[:tgt_duration] + self.current_prices_direct = self.last_successful_prices_direct[ + :tgt_duration + ] + + # Extend if needed + if len(prices) < tgt_duration: + remaining_hours = tgt_duration - len(prices) + prices.extend(self.last_successful_prices[:remaining_hours]) + self.current_prices_direct.extend( + self.last_successful_prices_direct[:remaining_hours] + ) + else: + if len(self.last_successful_prices) == 0: + logger.error( + "[PRICE-IF] No prices retrieved (failure %d) and no previous" + + " successful prices available. Using default prices (0.10 ct/kWh).", + self.consecutive_failures, + ) + else: + logger.error( + "[PRICE-IF] No prices retrieved after %d consecutive failures." + + " Using default prices (0.10 ct/kWh).", + self.consecutive_failures, + ) + prices = self.default_prices[:tgt_duration] + self.current_prices_direct = self.default_prices[:tgt_duration].copy() + else: + # Success - reset failure counter and store successful prices + self.consecutive_failures = 0 + self.last_successful_prices = prices.copy() + self.last_successful_prices_direct = self.current_prices_direct.copy() + logger.debug("[PRICE-IF] Prices retrieved successfully. Stored as backup.") return prices + def __determine_price_currency(self): + """ + Determine the currency used by the configured price source. + + Returns: + str: ISO 4217 currency code. + """ + if self.src == "stromligning": + return "DKK" + if self.src == "smartenergy_at": + return "EUR" + if self.src == "fixed_24h": + return "EUR" + if self.src == "tibber": + # Tibber exposes prices in the account currency; default to EUR. + return "EUR" + if self.src == "default": + return "EUR" + return "EUR" + def __retrieve_prices_from_akkudoktor(self, tgt_duration, start_time=None): """ Fetches and processes electricity prices for today and tomorrow. @@ -410,6 +569,7 @@ def __retrieve_prices_from_akkudoktor(self, tgt_duration, start_time=None): self.src, ) return [] + return [] logger.debug("[PRICE-IF] Fetching prices from akkudoktor ...") if start_time is None: start_time = datetime.now(self.time_zone).replace( @@ -433,12 +593,15 @@ def __retrieve_prices_from_akkudoktor(self, tgt_duration, start_time=None): "[PRICE-IF] Request timed out while fetching prices from akkudoktor." ) return [] + return [] except requests.exceptions.RequestException as e: logger.error( + "[PRICE-IF] Request failed while fetching prices from akkudoktor: %s", "[PRICE-IF] Request failed while fetching prices from akkudoktor: %s", e, ) return [] + return [] prices = [] for price in data["values"]: @@ -450,6 +613,14 @@ def __retrieve_prices_from_akkudoktor(self, tgt_duration, start_time=None): price_with_fixed * (1 + self.relative_price_multiplier), 9 ) prices.append(price_final) + price_with_fixed = ( + round(price["marketpriceEurocentPerKWh"] / 100000, 9) + + self.fixed_price_adder_ct / 100000 + ) + price_final = round( + price_with_fixed * (1 + self.relative_price_multiplier), 9 + ) + prices.append(price_final) if start_time is None: start_time = datetime.now(self.time_zone).replace( @@ -488,6 +659,7 @@ def __retrieve_prices_from_tibber(self, tgt_duration, start_time=None): "[PRICE-IF] Price source '%s' currently not supported.", self.src ) return [] # Changed from self.default_prices to [] + return [] # Changed from self.default_prices to [] headers = { "Authorization": self.access_token, "Content-Type": "application/json", @@ -502,6 +674,7 @@ def __retrieve_prices_from_tibber(self, tgt_duration, start_time=None): total energy startsAt + currency } tomorrow { total @@ -524,12 +697,15 @@ def __retrieve_prices_from_tibber(self, tgt_duration, start_time=None): "[PRICE-IF] Request timed out while fetching prices from Tibber." ) return [] # Changed from self.default_prices to [] + return [] # Changed from self.default_prices to [] except requests.exceptions.RequestException as e: logger.error( + "[PRICE-IF] Request failed while fetching prices from Tibber: %s", "[PRICE-IF] Request failed while fetching prices from Tibber: %s", e, ) return [] # Changed from self.default_prices to [] + return [] # Changed from self.default_prices to [] response.raise_for_status() data = response.json() @@ -550,6 +726,18 @@ def __retrieve_prices_from_tibber(self, tgt_duration, start_time=None): "tomorrow" ] ) + try: + self.price_currency = ( + ( + data["data"]["viewer"]["homes"][0]["currentSubscription"][ + "priceInfo" + ]["today"][0]["currency"] + ) + .strip() + .upper() + ) + except (KeyError, IndexError, TypeError): + pass today_prices_json = json.loads(today_prices) tomorrow_prices_json = json.loads(tomorrow_prices) @@ -593,14 +781,154 @@ def __retrieve_prices_from_tibber(self, tgt_duration, start_time=None): logger.debug("[PRICE-IF] Prices from TIBBER fetched successfully.") return extended_prices + def __retrieve_prices_from_stromligning(self, tgt_duration, start_time=None): + logger.debug("[PRICE-IF] Prices fetching from STROMLIGNING started") + if self.src != "stromligning": + logger.error( + "[PRICE-IF] Price source '%s' currently not supported.", + self.src, + ) + return [] + + if start_time is None: + start_time = datetime.now(self.time_zone).replace( + minute=0, second=0, microsecond=0 + ) + + if start_time.tzinfo is None and hasattr(self.time_zone, "localize"): + start_time = self.time_zone.localize(start_time) + + headers = {"accept": "application/json"} + + request_url = self._stromligning_url + to_param = (start_time + timedelta(hours=tgt_duration)).strftime( + "%Y-%m-%dT%H:%M" + ) + request_url = f"{request_url}&forecast=true&to={to_param}" + + try: + response = requests.get(request_url, headers=headers, timeout=10) + response.raise_for_status() + data = response.json() + except requests.exceptions.Timeout: + logger.error( + "[PRICE-IF] Request timed out while fetching prices from STROMLIGNING." + ) + return [] + except requests.exceptions.RequestException as e: + logger.error( + "[PRICE-IF] Request failed while fetching prices from STROMLIGNING: %s", + e, + ) + return [] + except ValueError as e: + logger.error( + "[PRICE-IF] Failed to parse STROMLIGNING response as JSON: %s", + e, + ) + return [] + + if not isinstance(data, list) or len(data) == 0: + logger.error("[PRICE-IF] STROMLIGNING API returned no price entries.") + return [] + + tzinfo = start_time.tzinfo + horizon_end = start_time + timedelta(hours=tgt_duration) + + processed_entries = [] + for entry in data: + try: + price_value = float(entry["price"]) + entry_start = entry["date"] + resolution_value = str(entry.get("resolution", "15m")).lower() + except (KeyError, TypeError, ValueError): + logger.debug( + "[PRICE-IF] Skipping malformed STROMLIGNING entry: %s", entry + ) + continue + + try: + entry_start_dt = datetime.fromisoformat( + entry_start.replace("Z", "+00:00") + ) + except ValueError: + logger.debug( + "[PRICE-IF] Skipping STROMLIGNING entry with invalid datetime: %s", + entry_start, + ) + continue + + if tzinfo is not None: + entry_start_dt = entry_start_dt.astimezone(tzinfo) + + resolution_map = {"15m": 15, "30m": 30, "60m": 60} + minutes = resolution_map.get(resolution_value, 15) + entry_end_dt = entry_start_dt + timedelta(minutes=minutes) + + if entry_end_dt <= start_time or entry_start_dt >= horizon_end: + continue + + processed_entries.append( + (entry_start_dt, entry_end_dt, price_value / 1000.0) + ) + + if not processed_entries: + logger.error( + "[PRICE-IF] No relevant STROMLIGNING price entries found within horizon." + ) + return [] + + processed_entries.sort(key=lambda item: item[0]) + + hourly_prices = [] + current_slot_start = start_time + coverage_warning = False + + while current_slot_start < horizon_end: + current_slot_end = current_slot_start + timedelta(hours=1) + weighted_sum = 0.0 + covered_seconds = 0.0 + + for entry_start, entry_end, price_per_wh in processed_entries: + overlap_start = max(entry_start, current_slot_start) + overlap_end = min(entry_end, current_slot_end) + if overlap_start >= overlap_end: + continue + duration = (overlap_end - overlap_start).total_seconds() + weighted_sum += price_per_wh * duration + covered_seconds += duration + + if covered_seconds == 0: + coverage_warning = True + if hourly_prices: + hourly_prices.append(hourly_prices[-1]) + else: + hourly_prices.append(processed_entries[0][2]) + else: + hourly_prices.append(round(weighted_sum / covered_seconds, 9)) + + current_slot_start = current_slot_end + + if coverage_warning: + logger.warning( + "[PRICE-IF] Incomplete STROMLIGNING price coverage detected; " + "missing intervals reused the prior value." + ) + + self.current_prices_direct = hourly_prices.copy() + logger.debug("[PRICE-IF] Prices from STROMLIGNING fetched successfully.") + return hourly_prices + def __retrieve_prices_from_smartenergy_at(self, tgt_duration, start_time=None): logger.debug("[PRICE-IF] Prices fetching from SMARTENERGY_AT started") if self.src != "smartenergy_at": logger.error( + "[PRICE-IF] Price source '%s' currently not supported.", "[PRICE-IF] Price source '%s' currently not supported.", self.src, ) return [] + return [] if start_time is None: start_time = datetime.now(self.time_zone).replace( minute=0, second=0, microsecond=0 @@ -618,12 +946,15 @@ def __retrieve_prices_from_smartenergy_at(self, tgt_duration, start_time=None): "[PRICE-IF] Request timed out while fetching prices from SMARTENERGY_AT." ) return [] + return [] except requests.exceptions.RequestException as e: logger.error( + "[PRICE-IF] Request failed while fetching prices from SMARTENERGY_AT: %s", "[PRICE-IF] Request failed while fetching prices from SMARTENERGY_AT: %s", e, ) return [] + return [] # Summarize to hourly averages hourly = defaultdict(list) @@ -636,6 +967,7 @@ def __retrieve_prices_from_smartenergy_at(self, tgt_duration, start_time=None): values = hourly.get(hour, []) avg = sum(values) / len(values) if values else 0 hourly_prices.append(round(avg, 9)) + hourly_prices.append(round(avg, 9)) # Optionally extend to tgt_duration if needed extended_prices = hourly_prices @@ -645,7 +977,9 @@ def __retrieve_prices_from_smartenergy_at(self, tgt_duration, start_time=None): # Catch case where all prices are zero (or data is empty) if not any(extended_prices): - logger.error("[PRICE-IF] SMARTENERGY_AT API returned only zero prices or empty data.") + logger.error( + "[PRICE-IF] SMARTENERGY_AT API returned only zero prices or empty data." + ) return [] logger.debug("[PRICE-IF] Prices from SMARTENERGY_AT fetched successfully.") @@ -672,9 +1006,12 @@ def __retrieve_prices_from_fixed24h_array(self, tgt_duration, start_time=None): + " but no 'fixed_24h_array' is provided." ) return [] + return [] if len(self.fixed_24h_array) != 24: logger.error("[PRICE-IF] fixed_24h_array must contain exactly 24 entries.") return [] + logger.error("[PRICE-IF] fixed_24h_array must contain exactly 24 entries.") + return [] # Convert each entry in fixed_24h_array from ct/kWh to €/Wh (divide by 100000) extended_prices = [round(price / 100000, 9) for price in self.fixed_24h_array] # Extend to tgt_duration if needed diff --git a/src/interfaces/pv_interface.py b/src/interfaces/pv_interface.py index 2d969043..56a26818 100644 --- a/src/interfaces/pv_interface.py +++ b/src/interfaces/pv_interface.py @@ -30,6 +30,9 @@ import math import sys from collections import defaultdict +import math +import sys +from collections import defaultdict import aiohttp import pytz import requests @@ -61,6 +64,7 @@ def __init__( self.config_source = config_source self.config_special = config_special logger.debug( + "[PV-IF] Initializing with 1st source: %s", "[PV-IF] Initializing with 1st source: %s", self.config_source.get("source", "akkudoktor"), # self.config_source.get("second_source", "openmeteo"), @@ -96,6 +100,24 @@ def __init__( logger.error("[PV-IF] We have to exit now ...") sys.exit(1) # Exit if configuration is invalid + # Adjust update interval based on provider + if self.config_source.get("source") == "solcast": + self.update_interval = ( + 2.5 * 60 * 60 + ) # 2.5 hours (9.6 calls/day - under the 10 limit) + logger.info("[PV-IF] Using extended update interval for Solcast: 2.5 hours") + else: + self.update_interval = 15 * 60 # Standard 15 minutes + + try: + self.__check_config() # Validate configuration parameters + self.configuration_valid = True + logger.info("[PV-IF] Configuration validation successful") + except ValueError as e: + logger.error("[PV-IF] PV Interface configuration error: %s", str(e)) + logger.error("[PV-IF] We have to exit now ...") + sys.exit(1) # Exit if configuration is invalid + logger.info("[PV-IF] Initialized") self.__start_update_service() # Start the background thread for periodic updates @@ -148,49 +170,108 @@ def __check_config(self): + " CONFIG_README.md for setup instructions" ) + if config_entry.get("azimuth") is None: + config_entry["azimuth"] = 180 + logger.debug( + "[PV-IF] Solcast config - setting default azimuth for '%s'", + entry_name, + ) + if config_entry.get("tilt") is None: + config_entry["tilt"] = 25 + logger.debug( + "[PV-IF] Solcast config - setting default tilt for '%s'", + entry_name, + ) + logger.debug("[PV-IF] Solcast config validated for '%s'", entry_name) + + # lat, long - required for all sources for temperature forecast + missing = [] + if config_entry.get("lat") is None: + missing.append("lat") + if config_entry.get("lon") is None: + missing.append("lon") + if missing: + raise ValueError( + "[PV-IF] Missing required parameters " + + f"for '{entry_name}': {', '.join(missing)}" + ) + # azimuth, tilt - only solcast and evcc not required but have defaults + if self.config_source.get("source") in ("solcast", "evcc"): + if config_entry.get("azimuth") is None: + config_entry["azimuth"] = 180 + logger.debug( + "[PV-IF] Solcast config - setting default azimuth for '%s'", + entry_name, + ) + if config_entry.get("tilt") is None: + config_entry["tilt"] = 25 + logger.debug( + "[PV-IF] Solcast config - setting default tilt for '%s'", + entry_name, + ) else: - # Standard parameter validation for other sources missing = [] - if config_entry.get("lat") is None: - missing.append("lat") - if config_entry.get("lon") is None: - missing.append("lon") if config_entry.get("azimuth") is None: missing.append("azimuth") if config_entry.get("tilt") is None: missing.append("tilt") - if missing: - logger.error( - "[PV-IF] Missing parameters for '%s': %s", - entry_name, - ", ".join(missing), - ) raise ValueError( "[PV-IF] Missing required parameters " + f"for '{entry_name}': {', '.join(missing)}" ) + # power - only evcc, solcast not required + if self.config_source.get("source") not in ("evcc", "solcast"): + missing_common = [] + if config_entry.get("power") is None: + missing_common.append("power") + if missing_common: + raise ValueError( + "[PV-IF] Missing required parameters" + + f" for '{entry_name}': {', '.join(missing_common)}" + ) + else: # to get a working temperature forecast we set dummy values here + config_entry["power"] = 1000 + # powerInverter, inverterEfficiency - only evcc, forecast_solar not required + if self.config_source.get("source") not in ( + "evcc", + "forecast_solar", + "solcast", + ): + missing_common = [] + if config_entry.get("powerInverter") is None: + missing_common.append("powerInverter") + if config_entry.get("inverterEfficiency") is None: + missing_common.append("inverterEfficiency") + + if missing_common: + raise ValueError( + "[PV-IF] Missing required parameters" + + f" for '{entry_name}': {', '.join(missing_common)}" + ) + else: # to get a working temperature forecast we set dummy values here + config_entry["powerInverter"] = 1000 + config_entry["inverterEfficiency"] = 100 - # Common parameters for all sources - missing_common = [] - if config_entry.get("power") is None: - missing_common.append("power") - if config_entry.get("powerInverter") is None: - missing_common.append("powerInverter") - if config_entry.get("inverterEfficiency") is None: - missing_common.append("inverterEfficiency") - - if missing_common: - logger.error( - "[PV-IF] Missing common parameters for '%s': %s", - entry_name, - ", ".join(missing_common), - ) - raise ValueError( - "[PV-IF] Missing required parameters" - + f" for '{entry_name}': {', '.join(missing_common)}" - ) + # horizon parameter check for specific sources + if self.config_source.get("source") in [ + "openmeteo_local", + "forecast_solar", + ]: + for config_entry in self.config: + if "horizon" not in config_entry or not config_entry["horizon"]: + logger.warning( + "[PV-IF] 'horizon' parameter missing for '%s' " + + "- using default (no shading)", + config_entry.get("name", "unnamed"), + ) + # For forecast_solar, default is 24 values + config_entry["horizon"] = [0] * ( + 24 + if self.config_source.get("source") == "forecast_solar" + else 36 + ) def __start_update_service(self): """ @@ -240,6 +321,10 @@ def __update_pv_state_loop(self): ) # special temp forecast if pv config is not given in detail if self.config and self.config[0]: + temp_result = self.__get_pv_forecast_akkudoktor_api( + tgt_value="temperature", + pv_config_entry=self.config[0], + tgt_duration=48, temp_result = self.__get_pv_forecast_akkudoktor_api( tgt_value="temperature", pv_config_entry=self.config[0], @@ -257,6 +342,18 @@ def __update_pv_state_loop(self): # "[PV-IF] Temperature forecast updated with %d values", # len(temp_result), # ) + if not temp_result: # If empty array or None due to API error + logger.warning( + "[PV-IF] Temperature forecast API failed - using default" + + " temperature forecast" + ) + self.temp_forecast_array = self.__get_default_temperature_forecast() + else: + self.temp_forecast_array = temp_result + # logger.debug( + # "[PV-IF] Temperature forecast updated with %d values", + # len(temp_result), + # ) else: self.temp_forecast_array = self.__get_default_temperature_forecast() logger.info("[PV-IF] PV and Temperature updated") @@ -296,7 +393,7 @@ def __create_forecast_request(self, pv_config_entry): Creates a forecast request URL for the EOS server. """ horizon_string = "" - if pv_config_entry["horizon"] != "": + if pv_config_entry.get("horizon", "") != "": horizon_string = "&horizont=" + str(pv_config_entry["horizon"]) return ( EOS_API_GET_PV_FORECAST @@ -382,6 +479,7 @@ def get_pv_forecast(self, config_entry, tgt_duration=24): Notes: - Supported sources: "akkudoktor", "openmeteo", "forecast_solar", "solcast", "default". + "solcast", "default". - Logs a warning if the default source is used. - Logs an error and falls back to the default forecast if no valid source is configured. @@ -393,14 +491,18 @@ def get_pv_forecast(self, config_entry, tgt_duration=24): elif self.config_source.get("source") == "openmeteo": # return self.__get_pv_forecast_openmeteo_api(config_entry, tgt_duration) return self.__get_pv_forecast_openmeteo_lib(config_entry) + return self.__get_pv_forecast_openmeteo_lib(config_entry) elif self.config_source.get("source") == "openmeteo_local": return self.__get_pv_forecast_openmeteo_api(config_entry, tgt_duration) elif self.config_source.get("source") == "forecast_solar": return self.__get_pv_forecast_forecast_solar_api(config_entry) + return self.__get_pv_forecast_forecast_solar_api(config_entry) elif self.config_source.get("source") == "evcc": return self.__get_pv_forecast_evcc_api(config_entry, tgt_duration) elif self.config_source.get("source") == "solcast": return self.__get_pv_forecast_solcast_api(config_entry, tgt_duration) + elif self.config_source.get("source") == "solcast": + return self.__get_pv_forecast_solcast_api(config_entry, tgt_duration) elif self.config_source.get("source") == "default": logger.warning("[PV-IF] Using default PV forecast source") return self.__get_default_pv_forcast(config_entry["power"]) @@ -445,8 +547,17 @@ def __get_pv_forecast_akkudoktor_api( "akkudoktor", ) + return self._handle_interface_error( + "config_error", + f"No PV config entry provided for target: {tgt_value}", + {}, + "akkudoktor", + ) + forecast_request_payload = self.__create_forecast_request(pv_config_entry) + def request_func(): + def request_func(): response = requests.get(forecast_request_payload, timeout=5) response.raise_for_status() @@ -471,7 +582,50 @@ def error_handler(error_type, exception): datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) ) end_time = current_time + timedelta(hours=tgt_duration) + return day_values["values"] + + def error_handler(error_type, exception): + return self._handle_interface_error( + error_type, + f"Akkudoktor API error for {tgt_value}: {exception}", + pv_config_entry, + "akkudoktor", + ) + + day_values = self._retry_request(request_func, error_handler) + + # Data processing + try: + forecast_values = [] + tz = pytz.timezone(self.time_zone) + current_time = tz.localize( + datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + ) + end_time = current_time + timedelta(hours=tgt_duration) + + for forecast_entry in day_values: + for forecast in forecast_entry: + entry_time = datetime.fromisoformat(forecast["datetime"]) + if entry_time.tzinfo is None: + # If datetime is naive, localize it + entry_time = pytz.timezone(self.time_zone).localize(entry_time) + else: + # Convert to configured timezone + entry_time = entry_time.astimezone( + pytz.timezone(self.time_zone) + ) + if current_time <= entry_time < end_time: + value = forecast.get(tgt_value, 0) + # if power is negative, set it to 0 (fixing wrong values from api) + if tgt_value == "power" and value < 0: + value = 0 + forecast_values.append(value) + # workaround for wrong time points in the forecast from akkudoktor + # remove first entry and append 0 to the end + if forecast_values: + forecast_values.pop(0) + forecast_values.append(0) for forecast_entry in day_values: for forecast in forecast_entry: entry_time = datetime.fromisoformat(forecast["datetime"]) @@ -534,6 +688,44 @@ def error_handler(error_type, exception): ) return forecast_values + # fix for time changes e.g. western europe then fill or reduce + # the array to target duration + if len(forecast_values) > tgt_duration: + forecast_values = forecast_values[:tgt_duration] + logger.debug( + "[PV-IF][akkudoktor] Day of time change - values reduced to %s for %s", + tgt_duration, + pv_config_entry.get("name", "unknown"), + ) + elif len(forecast_values) < tgt_duration: + if forecast_values: + forecast_values.extend( + [forecast_values[-1]] * (tgt_duration - len(forecast_values)) + ) + else: + forecast_values = [0] * tgt_duration + logger.debug( + "[PV-IF][akkudoktor] Day of time change - values extended to %s for %s", + tgt_duration, + pv_config_entry.get("name", "unknown"), + ) + + # Clear any previous errors on success + self.pv_forcast_request_error["error"] = None + + request_type = ( + "PV forecast" if tgt_value == "power" else "Temperature forecast" + ) + pv_config_name = ( + f"for {pv_config_entry.get('name', 'unknown')}" + if tgt_value == "power" + else "" + ) + logger.debug( + "[PV-IF] %s fetched successfully %s", request_type, pv_config_name + ) + + return forecast_values except (ValueError, TypeError, AttributeError, KeyError) as e: return self._handle_interface_error( @@ -543,39 +735,39 @@ def error_handler(error_type, exception): "akkudoktor", ) - def __get_horizon_elevation(self, sun_azimuth, horizon): + def __get_horizon_elevation(self, sun_azimuth, horizon_for_elev): - if not horizon or len(horizon) == 0: - horizon = [0] * 36 + if not horizon_for_elev or len(horizon_for_elev) == 0: + horizon_for_elev = [0] * 36 - # Normalize horizon string to a list of integers (handle '50t0.4' as 50) - if isinstance(horizon, str): - horizon = [ + # Normalize horizon_for_elev string to a list of integers (handle '50t0.4' as 50) + if isinstance(horizon_for_elev, str): + horizon_for_elev = [ int(float(x.split("t")[0])) if "t" in x else int(float(x)) - for x in horizon.split(",") + for x in horizon_for_elev.split(",") if x.strip() ] else: - horizon = [int(float(x)) for x in horizon] - # Expand horizon to 36 values by linear interpolation if needed - if len(horizon) != 36: + horizon_for_elev = [int(float(x)) for x in horizon_for_elev] + # Expand horizon_for_elev to 36 values by linear interpolation if needed + if len(horizon_for_elev) != 36: # Interpolate to 36 values (full circle) - x_old = np.linspace(0, 360, num=len(horizon), endpoint=False) + x_old = np.linspace(0, 360, num=len(horizon_for_elev), endpoint=False) x_new = np.linspace(0, 360, num=36, endpoint=False) - horizon = np.interp(x_new, x_old, horizon).tolist() + horizon_for_elev = np.interp(x_new, x_old, horizon_for_elev).tolist() # logger.debug( # "[PV-IF] Horizon elevation values normalized to 36 values: %s", - # horizon + # horizon_for_elev # ) idx = int((sun_azimuth / 10)) # Convert azimuth to index (0-35) # logger.debug( - # "[PV-IF] azimuth %s° to horizon index %s - elevation: %s°", + # "[PV-IF] azimuth %s° to horizon_for_elev index %s - elevation: %s°", # round(sun_azimuth,2), # idx, - # horizon[idx] + # horizon_for_elev[idx] # ) - return horizon[idx] + return horizon_for_elev[idx] def __get_pv_forecast_openmeteo_api(self, pv_config_entry, hours=48): """ @@ -589,7 +781,9 @@ def __get_pv_forecast_openmeteo_api(self, pv_config_entry, hours=48): installed_power_watt = pv_config_entry.get( "power", 200 ) # value in config is in watts - horizon = pv_config_entry.get("horizon", [0] * 36) # default: no shading + horizon_openmeteo_api = pv_config_entry.get( + "horizon", [0] * 36 + ) # default: no shading pv_efficiency = pv_config_entry.get("inverterEfficiency", 0.85) cloud_factor = 0.3 # factor to adjust radiation based on cloud cover timezone = self.time_zone @@ -601,7 +795,7 @@ def __get_pv_forecast_openmeteo_api(self, pv_config_entry, hours=48): tilt, azimuth, installed_power_watt, - horizon, + horizon_openmeteo_api, ) # Fetch weather data @@ -633,6 +827,26 @@ def json_func(): data = self._retry_request(json_func, error_handler) + def request_func(): + response = requests.get(url, timeout=5) + response.raise_for_status() + return response + + def error_handler(error_type, exception): + return self._handle_interface_error( + error_type, + f"Open-Meteo API error for {pv_config_entry['name']}: {exception}", + pv_config_entry, + "openmeteo_api", + ) + + response = self._retry_request(request_func, error_handler) + + def json_func(): + return response.json() + + data = self._retry_request(json_func, error_handler) + radiation = data["hourly"]["shortwave_radiation"][:hours] # W/m² cloudcover = data["hourly"]["cloudcover"][:hours] # % @@ -647,6 +861,31 @@ def json_func(): logger.debug( "[PV-IF] Open-Meteo solar position calculated - first entry: %s", solpos[0] ) + # Prepare time index - create datetime objects instead of pandas DatetimeIndex + start_time = datetime.fromisoformat( + data["hourly"]["time"][0].replace("Z", "+00:00") + ) + times = [start_time + timedelta(hours=i) for i in range(hours)] + + # Get sun position using our custom function + solpos = self._solar_position(times, latitude, longitude) + logger.debug( + "[PV-IF] Open-Meteo solar position calculated - first entry: %s", solpos[0] + ) + + # Calculate PV forecast + pv_forecast = [] + for i, (rad, cc) in enumerate(zip(radiation, cloudcover)): + # Calculate angle of incidence (AOI) using our custom function + aoi = self._angle_of_incidence( + surface_tilt=tilt, + surface_azimuth=azimuth, + solar_zenith=solpos[i]["apparent_zenith"], + solar_azimuth=solpos[i]["azimuth"], + ) + + sun_az = solpos[i]["azimuth"] + sun_el = 90 - solpos[i]["apparent_zenith"] # Calculate PV forecast pv_forecast = [] @@ -667,12 +906,13 @@ def json_func(): # Project radiation onto panel projection = max(math.cos(math.radians(aoi)), 0) + projection = max(math.cos(math.radians(aoi)), 0) # Adjust for panel efficiency (22,5% is a common value) eff_rad_panel = eff_rad * projection * 0.225 # --- Horizon check --- - horizon_elev = self.__get_horizon_elevation(sun_az, horizon) + horizon_elev = self.__get_horizon_elevation(sun_az, horizon_openmeteo_api) if sun_el < horizon_elev: eff_rad_panel = ( eff_rad_panel * 0.25 @@ -695,15 +935,19 @@ def json_func(): return pv_forecast + def __get_pv_forecast_openmeteo_lib(self, pv_config_entry): def __get_pv_forecast_openmeteo_lib(self, pv_config_entry): """ Synchronous wrapper for the async OpenMeteoSolarForecast. """ return asyncio.run(self.__get_pv_forecast_openmeteo_lib_async(pv_config_entry)) + return asyncio.run(self.__get_pv_forecast_openmeteo_lib_async(pv_config_entry)) + async def __get_pv_forecast_openmeteo_lib_async(self, pv_config_entry): async def __get_pv_forecast_openmeteo_lib_async(self, pv_config_entry): """ Fetches PV forecast from OpenMeteo Solar Forecast library. + Fetches PV forecast from OpenMeteo Solar Forecast library. """ try: async with OpenMeteoSolarForecast( @@ -755,41 +999,110 @@ async def __get_pv_forecast_openmeteo_lib_async(self, pv_config_entry): ).total_seconds() // 3600 ) - - for hour in range( - -1 * hours_from_today_midnight, hours_until_tomorrow_midnight - ): - current_hour_energy = 0 - for minute in range(59): - current_hour_energy += estimate.power_production_at_time( - now + timedelta(hours=hour, minutes=minute) - ) - current_hour_energy = round(current_hour_energy / 60, 1) - # time_point = now + timedelta(hours=hour, minutes=0) - # logger.debug("TEST - : %s - %s", current_hour_energy, time_point) - pv_forecast.append(current_hour_energy) - - # Clear any previous errors on success - self.pv_forcast_request_error["error"] = None - - logger.debug( - "[PV-IF] OpenMeteo Lib PV forecast (Wh) (length: %s): %s", - len(pv_forecast), - pv_forecast, + except (aiohttp.ClientError, ConnectionError) as e: + return self._handle_interface_error( + "connection_error", + f"OpenMeteo Solar Forecast connection error: {e}", + pv_config_entry, + "openmeteo_lib", ) - return pv_forecast - - except (ValueError, TypeError, AttributeError) as e: + except (ValueError, KeyError, AttributeError, TypeError) as e: return self._handle_interface_error( - "processing_error", - f"Error processing OpenMeteo forecast data: {e}", + "api_error", + f"OpenMeteo Solar Forecast API error: {e}", pv_config_entry, "openmeteo_lib", ) - def __get_pv_forecast_forecast_solar_api(self, pv_config_entry): - """ - Fetches PV forecast from Forecast.Solar API. + # Data processing + try: + # Build an array of hourly values from now (hour=0) up + # to tomorrow midnight (48 hours) + pv_forecast = [] + # Calculate the number of hours remaining until tomorrow midnight + # Use the current time in the forecast's timezone + # Always use the start of the current hour in the forecast's timezone + now = datetime.now(estimate.timezone).replace( + minute=0, second=0, microsecond=0 + ) + # Find tomorrow's midnight in the forecast's timezone + tomorrow_midnight = (now + timedelta(days=2)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + hours_until_tomorrow_midnight = int( + (tomorrow_midnight - now).total_seconds() // 3600 + ) + hours_from_today_midnight = int( + ( + now - now.replace(hour=0, minute=0, second=0, microsecond=0) + ).total_seconds() + // 3600 + ) + + for hour in range( + -1 * hours_from_today_midnight, hours_until_tomorrow_midnight + ): + current_hour_energy = 0 + for minute in range(59): + current_hour_energy += estimate.power_production_at_time( + now + timedelta(hours=hour, minutes=minute) + ) + current_hour_energy = round(current_hour_energy / 60, 1) + # time_point = now + timedelta(hours=hour, minutes=0) + # logger.debug("TEST - : %s - %s", current_hour_energy, time_point) + pv_forecast.append(current_hour_energy) + for hour in range( + -1 * hours_from_today_midnight, hours_until_tomorrow_midnight + ): + current_hour_energy = 0 + for minute in range(59): + current_hour_energy += estimate.power_production_at_time( + now + timedelta(hours=hour, minutes=minute) + ) + current_hour_energy = round(current_hour_energy / 60, 1) + # time_point = now + timedelta(hours=hour, minutes=0) + # logger.debug("TEST - : %s - %s", current_hour_energy, time_point) + pv_forecast.append(current_hour_energy) + + # Clear any previous errors on success + self.pv_forcast_request_error["error"] = None + + logger.debug( + "[PV-IF] OpenMeteo Lib PV forecast (Wh) (length: %s): %s", + len(pv_forecast), + pv_forecast, + ) + return pv_forecast + + except (ValueError, TypeError, AttributeError) as e: + return self._handle_interface_error( + "processing_error", + f"Error processing OpenMeteo forecast data: {e}", + pv_config_entry, + "openmeteo_lib", + ) + # Clear any previous errors on success + self.pv_forcast_request_error["error"] = None + + logger.debug( + "[PV-IF] OpenMeteo Lib PV forecast (Wh) (length: %s): %s", + len(pv_forecast), + pv_forecast, + ) + return pv_forecast + + except (ValueError, TypeError, AttributeError) as e: + return self._handle_interface_error( + "processing_error", + f"Error processing OpenMeteo forecast data: {e}", + pv_config_entry, + "openmeteo_lib", + ) + + def __get_pv_forecast_forecast_solar_api(self, pv_config_entry): + def __get_pv_forecast_forecast_solar_api(self, pv_config_entry): + """ + Fetches PV forecast from Forecast.Solar API. """ latitude = pv_config_entry["lat"] longitude = pv_config_entry["lon"] @@ -797,36 +1110,50 @@ def __get_pv_forecast_forecast_solar_api(self, pv_config_entry): azimuth = pv_config_entry.get("azimuth", 180) # Convert to kW for API and round to 4 decimal places installed_power_watt = round(pv_config_entry.get("power", 200) / 1000, 4) - horizon = "" + horizon_forecast_solar_api = "" if pv_config_entry.get("horizon", None) is not None: - horizon = pv_config_entry.get("horizon", "") - if horizon: - # Convert horizon string to a list of floats - # Handle entries like '50t0.4' by taking only the value before 't' - horizon = [ + horizon_forecast_solar_api = pv_config_entry.get("horizon", [0] * 24) + if isinstance(horizon_forecast_solar_api, str): + # Convert horizon string to list of floats + horizon_forecast_solar_api = [ float(x.split("t")[0]) if "t" in x else float(x) - for x in horizon.split(",") + for x in horizon_forecast_solar_api.split(",") if x.strip() ] - # Ensure the list has 24 values, repeating if necessary - horizon = (horizon * (24 // len(horizon) + 1))[:24] + elif isinstance(horizon_forecast_solar_api, list): + # Use the list directly + pass else: - logger.debug( - "[PV-IF] No horizon values provided, using default empty list" - ) + # Fallback to default + horizon_forecast_solar_api = [0] * 24 + + # Ensure the list has 24 values, repeating if necessary + horizon_forecast_solar_api = ( + horizon_forecast_solar_api * (24 // len(horizon_forecast_solar_api) + 1) + )[:24] url = ( f"https://api.forecast.solar/estimate/" f"{latitude}/{longitude}/{tilt}/{azimuth}/{installed_power_watt}" - f"?horizon={','.join(map(str, horizon))}" + f"?horizon={','.join(map(str, horizon_forecast_solar_api))}" ) logger.debug("[PV-IF] Fetching PV forecast from Forecast.Solar API: %s", url) + def request_func(): + def request_func(): response = requests.get(url, timeout=5) response.raise_for_status() return response + def error_handler(error_type, exception): + return self._handle_interface_error( + error_type, + f"Forecast.Solar API error: {exception}", + pv_config_entry, + "forecast_solar", + return response + def error_handler(error_type, exception): return self._handle_interface_error( error_type, @@ -837,6 +1164,13 @@ def error_handler(error_type, exception): response = self._retry_request(request_func, error_handler) + def json_func(): + data = response.json() + watt_hours_period = data.get("result", {}).get("watt_hours_period", {}) + return watt_hours_period + + response = self._retry_request(request_func, error_handler) + def json_func(): data = response.json() watt_hours_period = data.get("result", {}).get("watt_hours_period", {}) @@ -844,6 +1178,9 @@ def json_func(): watt_hours_period = self._retry_request(json_func, error_handler) + # Data validation + watt_hours_period = self._retry_request(json_func, error_handler) + # Data validation if not watt_hours_period: return self._handle_interface_error( @@ -871,7 +1208,37 @@ def json_func(): for h in hours_list: # Use value if exact hour exists, else 0 forecast_wh.append(lookup.get(h, 0)) + return self._handle_interface_error( + "no_valid_data", + "No valid watt_hours_period data found.", + pv_config_entry, + "forecast_solar", + ) + # Data processing + try: + parsed = [ + (datetime.strptime(ts, "%Y-%m-%d %H:%M:%S"), v) + for ts, v in watt_hours_period.items() + ] + min_time = min(dt for dt, _ in parsed) + # Align to midnight of the first day + midnight = min_time.replace(hour=0, minute=0, second=0, microsecond=0) + # Build list of 48 hourly timestamps + hours_list = [midnight + timedelta(hours=i) for i in range(48)] + # Build a lookup dict for fast access + lookup = {dt: v for dt, v in parsed} + # Fill the forecast array + forecast_wh = [] + for h in hours_list: + # Use value if exact hour exists, else 0 + forecast_wh.append(lookup.get(h, 0)) + + # Clear any previous errors on success + self.pv_forcast_request_error["error"] = None + + pv_forecast = forecast_wh + return pv_forecast # Clear any previous errors on success self.pv_forcast_request_error["error"] = None @@ -886,6 +1253,14 @@ def json_func(): "forecast_solar", ) + except (ValueError, TypeError, AttributeError) as e: + return self._handle_interface_error( + "processing_error", + f"Error processing forecast data: {e}", + pv_config_entry, + "forecast_solar", + ) + def __get_pv_forecast_evcc_api(self, pv_config_entry, hours=48): """ Fetches PV forecast from an EVCC instance. @@ -893,12 +1268,15 @@ def __get_pv_forecast_evcc_api(self, pv_config_entry, hours=48): if self.config_special.get("url", "") == "": logger.error( "[PV-IF] No EVCC URL configured for EVCC PV forecast - using default PV forecast" + "[PV-IF] No EVCC URL configured for EVCC PV forecast - using default PV forecast" ) return self.__get_default_pv_forcast(pv_config_entry.get("power", 200)) url = self.config_special.get("url", "").rstrip("/") + "/api/state" logger.debug("[PV-IF] Fetching PV forecast from EVCC API: %s", url) + def request_func(): + def request_func(): response = requests.get(url, timeout=5) response.raise_for_status() @@ -935,6 +1313,80 @@ def json_func(): ) solar_forecast, solar_forecast_scale = result + if not solar_forecast or not isinstance(solar_forecast, list): + return self._handle_interface_error( + "no_valid_data", + "No valid solar forecast data found in EVCC API.", + pv_config_entry, + "evcc", + ) + + try: + # Get timezone-aware current time + tz = pytz.timezone(self.time_zone) + current_time = datetime.now(tz).replace(minute=0, second=0, microsecond=0) + midnight_today = current_time.replace( + hour=0, minute=0, second=0, microsecond=0 + ) + forecast_hours = [midnight_today + timedelta(hours=i) for i in range(hours)] + pv_forecast = [0.0] * hours # Initialize with zeros + + # with thanks for the hint from @forouher with PR #108 + # --- AGGREGATE 15-min intervals to hourly Wh --- + forecast_items = [] + for item in solar_forecast: + ts_str = item.get("ts", "") + if ts_str: + ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) + ts = ts.astimezone(tz) + # Convert W to Wh for 15 min: Wh = W * 0.25 + val_wh = item.get("val", 0) * 0.25 + forecast_items.append((ts, val_wh)) + + # Group by hour and sum Wh values + hourly_values = defaultdict(float) + for ts, val_wh in forecast_items: + hour_ts = ts.replace(minute=0, second=0, microsecond=0) + hourly_values[hour_ts] += val_wh + + # Fill forecast array for 48 hours from midnight + for i, hour in enumerate(forecast_hours): + pv_forecast[i] = hourly_values.get(hour, 0.0) + + # Apply scaling factor + return response + + def error_handler(error_type, exception): + return self._handle_interface_error( + error_type, + f"EVCC API error: {exception}", + pv_config_entry, + "evcc", + ) + + response = self._retry_request(request_func, error_handler) + + def json_func(): + data = response.json() + solar_forecast_all = data.get("forecast", {}).get("solar", {}) + solar_forecast_scale = solar_forecast_all.get("scale", "unknown") + solar_forecast = solar_forecast_all.get("timeseries", []) + logger.debug( + "[PV-IF] EVCC API solar forecast received with scale: %s", + solar_forecast_scale, + ) + return solar_forecast, solar_forecast_scale + + result = self._retry_request(json_func, error_handler) + if not result: + return self._handle_interface_error( + "no_valid_data", + "No valid solar forecast data found in EVCC API.", + pv_config_entry, + "evcc", + ) + solar_forecast, solar_forecast_scale = result + if not solar_forecast or not isinstance(solar_forecast, list): return self._handle_interface_error( "no_valid_data", @@ -981,6 +1433,14 @@ def json_func(): except (TypeError, ValueError): scale_factor = 1.0 + if scale_factor <= 0: + logger.debug( + "[PV-IF] EVCC PV forecast scale factor invalid (%s) - using 1.0", + scale_factor, + ) + scale_factor = 1.0 + + if scale_factor <= 0: logger.debug( "[PV-IF] EVCC PV forecast scale factor invalid (%s) - using 1.0", @@ -990,6 +1450,10 @@ def json_func(): pv_forecast = [val * scale_factor for val in pv_forecast] + # Clear any previous errors on success + self.pv_forcast_request_error["error"] = None + + # Clear any previous errors on success self.pv_forcast_request_error["error"] = None @@ -1059,7 +1523,9 @@ def __get_pv_forecast_solcast_api(self, pv_config_entry, tgt_duration=48): def request_func(): response = requests.get(url, params=params, headers=headers, timeout=15) - logger.debug("[PV-IF] Solcast API response status: %d", response.status_code) + logger.debug( + "[PV-IF] Solcast API response status: %d", response.status_code + ) if response.status_code == 429: raise requests.exceptions.RequestException("rate_limit") elif response.status_code == 403: @@ -1075,7 +1541,8 @@ def error_handler(error_type, exception): # Map custom error codes to messages error_map = { "rate_limit": "Solcast API rate limit exceeded", - "auth_error": "Solcast API authentication failed (403) - check API key and resource ID access.", + "auth_error": "Solcast API authentication failed (403) - check " + + "API key and resource ID access.", "not_found": f"Solcast resource ID '{resource_id}' not found - check resource ID", "bad_request": "Solcast API bad request - check parameters", } @@ -1403,3 +1870,154 @@ def _handle_interface_error( } ) return [] + + # Add these helper functions to replace pvlib functionality + def _solar_position(self, times, latitude, longitude): + """ + Calculate solar position (zenith and azimuth) for given times and location. + Simplified version of pvlib.solarposition.get_solarposition + """ + lat_rad = math.radians(latitude) + results = [] + + for t in times: + # Convert to Julian day number + a = (14 - t.month) // 12 + y = t.year - a + m = t.month + 12 * a - 3 + jdn = ( + t.day + + (153 * m + 2) // 5 + + 365 * y + + y // 4 + - y // 100 + + y // 400 + - 32045 + ) + + # Add fraction of day + hour_fraction = (t.hour + t.minute / 60 + t.second / 3600) / 24 + jd = jdn + hour_fraction - 0.5 + + # Number of days since J2000.0 + n = jd - 2451545.0 + + # Mean longitude of sun + long_of_sun = (280.460 + 0.9856474 * n) % 360 + + # Mean anomaly of sun + g = math.radians((357.528 + 0.9856003 * n) % 360) + + # Ecliptic longitude of sun + lambda_sun = math.radians( + long_of_sun + 1.915 * math.sin(g) + 0.020 * math.sin(2 * g) + ) + + # Obliquity of ecliptic + epsilon = math.radians(23.439 - 0.0000004 * n) + + # Right ascension and declination + alpha = math.atan2( + math.cos(epsilon) * math.sin(lambda_sun), math.cos(lambda_sun) + ) + delta = math.asin(math.sin(epsilon) * math.sin(lambda_sun)) + + # Greenwich mean sidereal time + gmst = (18.697375 + 24.06570982441908 * n) % 24 + + # Local sidereal time + lst = gmst + longitude / 15 + + # Hour angle + h = math.radians(15 * (lst - math.degrees(alpha) / 15)) + + # Solar zenith and azimuth + sin_alt = math.sin(lat_rad) * math.sin(delta) + math.cos( + lat_rad + ) * math.cos(delta) * math.cos(h) + altitude = math.asin(max(-1, min(1, sin_alt))) + zenith = math.degrees(math.pi / 2 - altitude) + + cos_az = (math.sin(delta) - math.sin(altitude) * math.sin(lat_rad)) / ( + math.cos(altitude) * math.cos(lat_rad) + ) + azimuth = math.degrees(math.acos(max(-1, min(1, cos_az)))) + + if math.sin(h) > 0: + azimuth = 360 - azimuth + + results.append({"apparent_zenith": zenith, "azimuth": azimuth}) + + return results + + def _angle_of_incidence( + self, surface_tilt, surface_azimuth, solar_zenith, solar_azimuth + ): + """ + Calculate angle of incidence between sun and tilted surface. + Simplified version of pvlib.irradiance.aoi + """ + # Convert to radians + surf_tilt_rad = math.radians(surface_tilt) + surf_az_rad = math.radians(surface_azimuth) + sun_zen_rad = math.radians(solar_zenith) + sun_az_rad = math.radians(solar_azimuth) + + # Calculate angle of incidence + cos_aoi = math.sin(sun_zen_rad) * math.sin(surf_tilt_rad) * math.cos( + sun_az_rad - surf_az_rad + ) + math.cos(sun_zen_rad) * math.cos(surf_tilt_rad) + + # Ensure value is within valid range for acos + cos_aoi = max(-1, min(1, cos_aoi)) + aoi = math.degrees(math.acos(cos_aoi)) + + return aoi + + def _retry_request(self, request_func, error_handler, max_retries=3, delay=1): + """ + Centralized retry logic for API requests. + + Args: + request_func (callable): Function that performs the request and returns the result. + error_handler (callable): Function to call on final failure. + max_retries (int): Number of retries before error handler is called. + delay (int): Delay in seconds between retries. + + Returns: + The result of request_func, or error_handler on failure. + """ + for attempt in range(max_retries): + try: + return request_func() + except requests.exceptions.Timeout as e: + if attempt == max_retries - 1: + return error_handler("timeout", e) + except requests.exceptions.RequestException as e: + if attempt == max_retries - 1: + return error_handler("request_failed", e) + except (ValueError, TypeError) as e: + if attempt == max_retries - 1: + return error_handler("invalid_json", e) + except (KeyError, AttributeError) as e: + if attempt == max_retries - 1: + return error_handler("parsing_error", e) + time.sleep(delay) + + def _handle_interface_error( + self, error_type, message, pv_config_entry, source="unknown" + ): + """ + Centralized error handling for all API errors. + """ + logger.error("[PV-IF] %s", message) + self.pv_forcast_request_error.update( + { + "error": error_type, + "timestamp": datetime.now().isoformat(), + "message": message, + "config_entry": pv_config_entry, + "source": source, + } + ) + return [] diff --git a/src/json/test/current_controls_multi_evcc.test.json b/src/json/test/current_controls_multi_evcc.test.json index 41ce7a5f..e41b5b89 100644 --- a/src/json/test/current_controls_multi_evcc.test.json +++ b/src/json/test/current_controls_multi_evcc.test.json @@ -75,6 +75,11 @@ "FANCONTROL_PERCENT_02_F32": 28.4 } }, + "localization": { + "currency": "EUR", + "currency_symbol": "\u20ac", + "currency_minor_unit": "ct" + }, "state": { "request_state": "response received", "last_request_timestamp": "2024-10-03T14:29:45.123Z", diff --git a/src/json/test/current_controls_no_evcc.test.json b/src/json/test/current_controls_no_evcc.test.json index 2b52116b..a8c40ee5 100644 --- a/src/json/test/current_controls_no_evcc.test.json +++ b/src/json/test/current_controls_no_evcc.test.json @@ -29,6 +29,11 @@ "FANCONTROL_PERCENT_02_F32": 10.1 } }, + "localization": { + "currency": "EUR", + "currency_symbol": "\u20ac", + "currency_minor_unit": "ct" + }, "state": { "request_state": "response received", "last_request_timestamp": "2024-10-03T14:29:45.123Z", diff --git a/src/json/test/current_controls_override_0.test.json b/src/json/test/current_controls_override_0.test.json index 5bda52f8..831b4be6 100644 --- a/src/json/test/current_controls_override_0.test.json +++ b/src/json/test/current_controls_override_0.test.json @@ -29,6 +29,11 @@ "FANCONTROL_PERCENT_02_F32": 10.1 } }, + "localization": { + "currency": "EUR", + "currency_symbol": "\u20ac", + "currency_minor_unit": "ct" + }, "state": { "request_state": "response received", "last_request_timestamp": "2024-10-03T14:29:45.123Z", diff --git a/src/json/test/current_controls_override_1.test.json b/src/json/test/current_controls_override_1.test.json index 46f50307..30eb82c7 100644 --- a/src/json/test/current_controls_override_1.test.json +++ b/src/json/test/current_controls_override_1.test.json @@ -29,6 +29,11 @@ "FANCONTROL_PERCENT_02_F32": 10.1 } }, + "localization": { + "currency": "EUR", + "currency_symbol": "\u20ac", + "currency_minor_unit": "ct" + }, "state": { "request_state": "response received", "last_request_timestamp": "2024-10-03T14:29:45.123Z", diff --git a/src/json/test/current_controls_override_2.test.json b/src/json/test/current_controls_override_2.test.json index 9e69507f..29fb9a6f 100644 --- a/src/json/test/current_controls_override_2.test.json +++ b/src/json/test/current_controls_override_2.test.json @@ -29,6 +29,11 @@ "FANCONTROL_PERCENT_02_F32": 10.1 } }, + "localization": { + "currency": "EUR", + "currency_symbol": "\u20ac", + "currency_minor_unit": "ct" + }, "state": { "request_state": "response received", "last_request_timestamp": "2024-10-03T14:29:45.123Z", diff --git a/src/json/test/current_controls_single_evcc.test.json b/src/json/test/current_controls_single_evcc.test.json index 12857ce5..42f0ab92 100644 --- a/src/json/test/current_controls_single_evcc.test.json +++ b/src/json/test/current_controls_single_evcc.test.json @@ -1,31 +1,32 @@ { "current_states": { "current_ac_charge_demand": 2500, - "current_dc_charge_demand": 1500, + "current_dc_charge_demand": 10000, "current_discharge_allowed": false, - "inverter_mode": "MODE_DISCHARGE_ALLOWED_EVCC_PV", - "inverter_mode_num": 4, + "inverter_mode": "MODE CHARGE FROM GRID EVCC FAST", + "inverter_mode_num": 6, "override_active": false, "override_end_time": 1728049800 }, "evcc": { "charging_state": true, - "charging_mode": "pv", + "charging_mode": "pv+now", "current_sessions": [ { "connected": true, "charging": true, + "mode": "pv+now", + "chargeDuration": 12330, + "chargeRemainingDuration": 7956, + "chargedEnergy": 37095.378, + "chargeRemainingEnergy": 17223.831, + "sessionEnergy": 37095.378, + "vehicleSoc": 78.806, + "vehicleRange": 249, + "vehicleOdometer": 28041, "vehicleName": "Model 3 Long Range", - "vehicleSoc": 68.5, - "vehicleRange": 312, - "vehicleOdometer": 45678, - "chargedEnergy": 12500, - "chargeRemainingEnergy": 8500, - "chargeDuration": 3600, - "chargeRemainingDuration": 5400, - "mode": "pv", - "targetSoc": 85, - "chargePower": 2200 + "smartCostActive": true, + "planActive": false } ] }, @@ -45,6 +46,11 @@ "FANCONTROL_PERCENT_02_F32": 18.2 } }, + "localization": { + "currency": "EUR", + "currency_symbol": "\u20ac", + "currency_minor_unit": "ct" + }, "state": { "request_state": "response received", "last_request_timestamp": "2024-10-03T14:29:45.123Z", diff --git a/src/json/test/optimize_request.test.json b/src/json/test/optimize_request.test.json index 461901d0..ec90a966 100644 --- a/src/json/test/optimize_request.test.json +++ b/src/json/test/optimize_request.test.json @@ -7,20 +7,19 @@ 0.0, 0.0, 0.0, - 14.642806901211323, - 48.59127160144763, - 72.02270524067268, - 115.82838852614212, - 217.64653479361104, - 264.3397029186528, - 151.98026891643715, - 84.53886062293004, - 373.6319079602918, - 173.7106321827244, - 58.26617885152783, - 8.983655076545293, 0.0, 0.0, + 64.84484090436757, + 299.2020966076683, + 580.3626167867155, + 1015.9595081323428, + 1238.0629758335187, + 1240.9415480478654, + 1272.118045120596, + 825.0354994015701, + 592.2822373767589, + 297.41070622506913, + 23.07136580634939, 0.0, 0.0, 0.0, @@ -31,74 +30,75 @@ 0.0, 0.0, 0.0, - 11.857061499757139, - 62.00636321344536, - 132.66302862000276, - 200.22191439236252, - 238.77424127457877, - 442.18558155297694, - 441.63921994432155, - 465.71867496818913, - 448.6882346462509, - 354.58951316725455, - 312.0142825430191, - 31.862256748155918, 0.0, 0.0, 0.0, + 17.50482403943947, + 92.96425409318756, + 353.57259331162226, + 453.1597659212524, + 495.97604226943423, + 433.8753556330854, + 401.4576218464926, + 394.4797220951195, + 292.6138050063678, + 133.86002670677556, + 10.399417822385736, + 0.0, + 0.0, 0.0, 0.0, 0.0 ], "strompreis_euro_pro_wh": [ - 0.0002185, - 0.0002133, - 0.0002131, - 0.0002109, - 0.0002114, - 0.0002128, - 0.0002142, - 0.0002152, - 0.0002125, - 0.0002105, - 0.0002089, - 0.0002088, - 0.0002075, - 0.0002065, - 0.0002074, - 0.0002089, - 0.0002202, - 0.0003103, - 0.0003446, - 0.0003524, - 0.0003504, - 0.0003335, - 0.0003236, - 0.000318, - 0.0003129, - 0.0003084, - 0.0003032, - 0.0003032, - 0.0003046, - 0.0003203, - 0.0003502, - 0.0003636, - 0.0003636, - 0.0003381, + 0.0003188, + 0.0003132, + 0.0003069, + 0.0003067, + 0.000308, + 0.0003083, + 0.0003104, + 0.0003104, + 0.0003127, + 0.0003023, + 0.0002918, + 0.0002807, + 0.0002501, + 0.0002331, + 0.000252, + 0.0002884, + 0.0003159, + 0.0003352, + 0.0003371, + 0.0003108, + 0.0002988, + 0.000296, + 0.0002979, + 0.0002814, + 0.0002923, + 0.0002851, + 0.0002815, + 0.0002804, + 0.0002811, + 0.000298, + 0.0002997, 0.0003182, - 0.0003016, - 0.000297, - 0.0002936, - 0.0002925, - 0.0003022, - 0.0003098, - 0.0003281, - 0.0003614, - 0.0003766, - 0.0003636, - 0.0003449, - 0.0003252, - 0.000316 + 0.0003578, + 0.0003186, + 0.000296, + 0.0002922, + 0.0002807, + 0.0002866, + 0.0002995, + 0.0003245, + 0.0003512, + 0.000365, + 0.0003578, + 0.0003315, + 0.0003068, + 0.0002997, + 0.0002903, + 0.0002696 ], "einspeiseverguetung_euro_pro_wh": [ 7.5e-05, @@ -112,10 +112,10 @@ 7.5e-05, 7.5e-05, 7.5e-05, - 0, - 0, - 0, - 0, + 7.5e-05, + 7.5e-05, + 7.5e-05, + 7.5e-05, 7.5e-05, 7.5e-05, 7.5e-05, @@ -152,54 +152,54 @@ ], "preis_euro_pro_wh_akku": 5e-05, "gesamtlast": [ - 457.01825, - 381.73350000000005, - 328.87925, - 269.65415, - 276.43295, - 275.9443, - 484.8195, - 533.04885, - 615.3107500000001, - 1824.6356999999998, - 2080.1855, - 2666.6145, - 2875.0967500000006, - 2242.4640999999997, - 2185.0618999999997, - 3182.24295, - 2231.1129, - 2318.37935, - 2285.34425, - 2501.2783, - 2075.7241, - 1545.7418, - 569.5178, - 785.47425, - 335.0448, - 336.1595, - 284.03679999999997, - 273.18825000000004, - 264.9767, - 268.6394, - 427.18555, - 461.95804999999996, - 966.0146, - 1323.7368, - 1258.32565, - 1672.6086500000001, - 1889.9933, - 1749.6637, - 1967.11305, - 1850.2103499999998, - 1921.96555, - 1629.21865, - 613.00045, - 1128.8386500000001, - 689.8242, - 446.90115000000003, - 437.75535, - 394.30165 + 441.2009, + 453.011, + 445.9075, + 5775.364799999999, + 357.49675000000013, + 299.31515, + 559.2502499999999, + 676.8467, + 936.3227999999999, + 763.43445, + 1227.5856999999999, + 1723.3557500000002, + 1337.0311, + 2803.1154500000002, + 974.3823500000001, + 520.35085, + 962.33565, + 1128.1313, + 855.85575, + 509.58774999999997, + 568.0126, + 830.80005, + 769.7812, + 557.54835, + 332.15475000000004, + 299.44005000000004, + 318.79830000000004, + 274.4614, + 288.01685, + 337.9028, + 424.03965, + 534.7079, + 663.1120000000001, + 568.696, + 472.48075, + 510.26784999999995, + 461.78935, + 473.90525, + 480.1841, + 495.5327, + 466.5235, + 947.8235, + 375.7647, + 433.2428, + 511.82975, + 592.1648, + 447.11855, + 465.7576 ] }, "pv_akku": { @@ -208,7 +208,7 @@ "charging_efficiency": 0.9, "discharging_efficiency": 0.9, "max_charge_power_w": 10000, - "initial_soc_percentage": 99, + "initial_soc_percentage": 42, "min_soc_percentage": 5, "max_soc_percentage": 100 }, @@ -233,104 +233,105 @@ "duration_h": 1 }, "temperature_forecast": [ - 19.6, - 18.8, - 18.5, - 18, - 18.2, - 18.5, - 18.5, - 18, - 19, - 18, - 18.7, - 19.5, - 21.2, - 19.4, - 18.4, - 17, - 15.3, - 14.6, - 14.4, - 13.8, - 12.1, - 12, - 11.8, + 5.5, + 4.9, + 4.7, + 4.9, + 4.6, + 4.8, + 4.9, + 4.7, + 5.3, + 6.5, + 8, + 9.8, + 11.4, + 12.7, + 13.2, + 13.3, + 13.3, + 12.5, + 11.6, 11.3, - 11, - 10.8, - 10.6, - 10.4, + 11.5, 10.5, - 10.4, - 10.5, - 10.3, 10, - 9.9, - 9.9, - 10.2, + 10.1, + 10.3, + 10.5, 10.5, 10.7, + 11, + 10.9, + 11.1, + 11.3, + 11, + 11.5, + 12.1, + 12.3, + 12.8, + 13.2, + 14, + 13.9, + 13.7, + 13, + 11.9, + 11.4, + 10.7, + 10.4, 10.3, - 10.3, - 10.2, - 10, - 9.8, - 9.5, - 9.3, - 8.9, - 8.7, 0 ], "start_solution": [ - 16.0, - 2.0, + 1.0, + 7.0, + 6.0, + 12.0, + 4.0, 14.0, - 3.0, - 2.0, - 3.0, - 15.0, 11.0, - 18.0, - 14.0, - 8.0, - 3.0, - 6.0, - 13.0, - 6.0, + 7.0, + 9.0, + 11.0, + 15.0, + 12.0, + 19.0, + 17.0, + 0.0, 13.0, - 20.0, - 3.0, + 11.0, 9.0, 8.0, - 10.0, - 13.0, - 13.0, + 11.0, 7.0, - 14.0, - 14.0, - 4.0, + 11.0, + 8.0, 6.0, + 10.0, + 12.0, + 7.0, 0.0, - 13.0, - 8.0, + 12.0, + 12.0, + 7.0, 13.0, 10.0, - 12.0, + 10.0, + 10.0, 13.0, 4.0, - 5.0, - 0.0, - 0.0, - 14.0, 2.0, + 13.0, 11.0, - 10.0, 12.0, + 11.0, + 11.0, + 12.0, + 13.0, 7.0, 7.0, - 12.0, - 9.0, - 17.0 - ] + 10.0, + 14.0 + ], + "timestamp": "2025-10-19T14:00:01.390960+02:00" } \ No newline at end of file diff --git a/src/json/test/optimize_response.test.json b/src/json/test/optimize_response.test.json index 2e730792..17ff0e68 100644 --- a/src/json/test/optimize_response.test.json +++ b/src/json/test/optimize_response.test.json @@ -1,14 +1,11 @@ { "ac_charge": [ - 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, - 0.375, 0.0, - 0.75, 0.0, 0.0, 0.0, @@ -16,8 +13,11 @@ 0.0, 0.0, 0.0, - 1.0, - 0.6, + 0.375, + 0.0, + 0.0, + 0.0, + 0.0, 0.0, 0.0, 0.0, @@ -101,21 +101,19 @@ ], "discharge_allowed": [ 0, + 1, 0, + 1, + 1, 0, - 0, + 1, 0, 0, 0, 1, - 0, - 0, 1, 0, 0, - 1, - 0, - 1, 0, 0, 1, @@ -124,7 +122,9 @@ 1, 1, 1, + 1, 0, + 1, 0, 0, 0, @@ -135,12 +135,12 @@ 1, 1, 1, + 1, 0, 0, - 0, - 0, - 0, - 0, + 1, + 1, + 1, 1, 1, 1, @@ -152,37 +152,41 @@ "eautocharge_hours_float": null, "result": { "Last_Wh_pro_Stunde": [ - 2319.37935, - 2285.34425, - 2501.2783, - 2075.7241, - 1545.7418, - 569.5178, - 785.47425, - 335.0448, - 336.1595, - 284.03679999999997, - 273.18825000000004, - 264.9767, - 268.6394, - 427.18555, - 461.95804999999996, - 966.0146, - 1323.7368, - 1258.32565, - 1672.6086500000001, - 1889.9933, - 1749.6637, - 1967.11305, - 1850.2103499999998, - 1921.96555, - 1629.21865, - 613.00045, - 1128.8386500000001, - 689.8242, - 446.90115000000003, - 437.75535, - 394.30165 + 6553.11545, + 975.3823500000001, + 520.35085, + 962.33565, + 1128.1313, + 855.85575, + 509.58774999999997, + 568.0126, + 830.80005, + 769.7812, + 557.54835, + 332.15475000000004, + 299.44005000000004, + 318.79830000000004, + 274.4614, + 288.01685, + 337.9028, + 424.03965, + 534.7079, + 663.1120000000001, + 568.696, + 472.48075, + 510.26784999999995, + 461.78935, + 473.90525, + 480.1841, + 495.5327, + 466.5235, + 947.8235, + 375.7647, + 433.2428, + 511.82975, + 592.1648, + 447.11855, + 465.7576 ], "EAuto_SoC_pro_Stunde": [ 50.0, @@ -215,6 +219,10 @@ 50.0, 50.0, 50.0, + 50.0, + 50.0, + 50.0, + 50.0, 50.0 ], "Einnahmen_Euro_pro_Stunde": [ @@ -248,13 +256,18 @@ 0.0, 0.0, 0.0, + 0.0, + 0.0, + 0.0, + 0.0, 0.0 ], - "Gesamt_Verluste": 2079.091999999999, - "Gesamtbilanz_Euro": 3.8810030947028977, + "Gesamt_Verluste": 1632.2820165272472, + "Gesamtbilanz_Euro": 1.9848913143429834, "Gesamteinnahmen_Euro": 0.0, - "Gesamtkosten_Euro": 3.8810030947028977, + "Gesamtkosten_Euro": 1.9848913143429834, "Home_appliance_wh_per_hour": [ + 0.0, 1.0, 0.0, 0.0, @@ -285,83 +298,86 @@ 0.0, 0.0, 0.0, + 0.0, + 0.0, + 0.0, 0.0 ], "Kosten_Euro_pro_Stunde": [ - 0.716915784134748, + 1.2382677365450425, + 0.018011901657393296, + 0.00705306576358647, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 0.10483551792000001, - 0.1036715898, - 0.08611995775999999, - 0.08283067740000001, - 0.08071190281999999, 0.0, + 0.15689410569, 0.0, + 0.08537035825500001, + 0.08974172145000002, + 0.07695897656, + 0.08096153653499999, 0.0, 0.0, 0.0, 0.0, - 0.3710955974436222, - 0.4301611617765365, - 0.37696625934933964, - 0.4441392584909716, - 0.45197661689085555, - 0.49876290265817264, 0.0, 0.0, 0.0, + 0.0016580621418297936, + 0.011472567725557728, + 0.0, + 0.0, 0.0, 0.0, - 0.008216546858651494, - 0.12459932139999999 - ], - "Netzbezug_Wh_pro_Stunde": [ - 2310.395694923455, 0.0, 0.0, 0.0, 0.0, + 0.09293303305957344, + 0.12556824896000002 + ], + "Netzbezug_Wh_pro_Stunde": [ + 5312.173901952135, + 71.47580022775118, + 24.455845227414944, 0.0, 0.0, - 335.0448, - 336.1595, - 284.03679999999997, - 273.18825000000004, - 264.9767, 0.0, 0.0, 0.0, 0.0, 0.0, + 557.54835, 0.0, - 1230.423068447023, - 1448.3540800556784, - 1283.945025031811, - 1518.424815353749, - 1495.6208368327452, - 1609.9512674569808, + 299.44005000000004, + 318.79830000000004, + 274.4614, + 288.01685, 0.0, 0.0, 0.0, 0.0, 0.0, - 25.26613425169586, - 394.30165 - ], - "Netzeinspeisung_Wh_pro_Stunde": [ 0.0, 0.0, + 5.906883298289254, + 40.02989436691462, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, + 0.0, + 320.1275682382826, + 465.7576 + ], + "Netzeinspeisung_Wh_pro_Stunde": [ 0.0, 0.0, 0.0, @@ -384,106 +400,130 @@ 0.0, 0.0, 0.0, - 0.0 - ], - "Verluste_Pro_Stunde": [ 0.0, - 253.92713888888875, - 277.9198111111109, - 230.6360111111112, - 171.74908888888876, - 63.27975555555554, - 87.27491666666663, 0.0, 0.0, 0.0, 0.0, 0.0, - 29.848822222222225, - 46.14760983336032, - 44.43907630961718, - 92.5946190422219, - 124.83498728973746, - 113.28348985838011, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 177.48404369464924, - 68.11116111111107, - 125.42651666666666, - 76.64713333333327, - 49.65568333333334, - 45.83213508314492, + 0.0 + ], + "Verluste_Pro_Stunde": [ + 375.0, + 22.525989489284456, + 28.022880417415507, + 41.11704584702676, + 92.30228819721447, + 92.53159824373893, + 56.62086111111108, + 63.112511111111075, + 92.31111666666663, + 85.53124444444438, + 0.0, + 36.906083333333356, + 0.0, + 0.0, + 0.0, + 0.0, + 37.544755555555525, + 47.11551666666668, + 59.411988888888914, + 71.73413066228454, + 52.85908287853471, + 13.212017409819737, + 6.34534267541639, + 2.8279808971144966, + 0.0, + 8.747386461500824, + 11.228108656097831, + 19.323299443736914, + 90.44038592146933, + 40.596142464179366, + 48.13808888888889, + 56.86997222222226, + 65.7960888888889, + 14.110109084635269, 0.0 ], "akku_soc_pro_stunde": [ - 99.0, - 99.0, - 87.5194348996795, - 74.95411203544624, - 64.52658644040551, - 56.76145899267565, - 53.90045186926686, - 49.95457445419015, - 49.95457445419015, - 49.95457445419015, - 49.95457445419015, - 49.95457445419015, - 49.95457445419015, - 48.605048175945186, - 46.51862038711435, - 44.50943889196935, - 40.32304685642265, - 34.67900185733876, - 29.557223674031952, - 29.557223674031952, - 29.557223674031952, - 29.557223674031952, - 29.557223674031952, - 29.557223674031952, - 29.557223674031952, - 21.53280719448365, - 18.453362348154364, - 12.782573096564409, - 9.317199494370117, - 7.0721645303890455, + 42.0, + 57.259065014919976, + 58.17566644996636, + 59.31594099448548, + 57.45695528840778, + 53.283780128030955, + 49.10023739162897, + 46.54029250008765, + 43.686846839941566, + 39.5132725264111, + 35.64623009741913, + 35.64623009741913, + 33.97763061585057, + 33.97763061585057, + 33.97763061585057, + 33.97763061585057, + 33.97763061585057, + 32.28015545735724, + 30.149966621718093, + 27.463829136914363, + 24.220582430056396, + 21.830715223322755, + 21.233372904405144, + 20.946486989068475, + 21.06155990738853, + 21.06155990738853, + 20.666072681531727, + 20.158426933448816, + 19.284781059331042, + 15.195786307478734, + 13.36035170832061, + 11.183930291877491, + 8.61272257769799, + 5.63794687967426, 5.0 ], "Electricity_price": [ - 0.0003103, - 0.0003446, - 0.0003524, - 0.0003504, - 0.0003335, - 0.0003236, - 0.000318, - 0.0003129, - 0.0003084, - 0.0003032, - 0.0003032, - 0.0003046, - 0.0003203, - 0.0003502, - 0.0003636, - 0.0003636, - 0.0003381, + 0.0002331, + 0.000252, + 0.0002884, + 0.0003159, + 0.0003352, + 0.0003371, + 0.0003108, + 0.0002988, + 0.000296, + 0.0002979, + 0.0002814, + 0.0002923, + 0.0002851, + 0.0002815, + 0.0002804, + 0.0002811, + 0.000298, + 0.0002997, 0.0003182, - 0.0003016, - 0.000297, - 0.0002936, - 0.0002925, - 0.0003022, - 0.0003098, - 0.0003281, - 0.0003614, - 0.0003766, - 0.0003636, - 0.0003449, - 0.0003252, - 0.000316 + 0.0003578, + 0.0003186, + 0.000296, + 0.0002922, + 0.0002807, + 0.0002866, + 0.0002995, + 0.0003245, + 0.0003512, + 0.000365, + 0.0003578, + 0.0003315, + 0.0003068, + 0.0002997, + 0.0002903, + 0.0002696 ] }, "eauto_obj": { @@ -597,56 +637,56 @@ "initial_soc_percentage": 50 }, "start_solution": [ - 16.0, - 2.0, - 14.0, - 3.0, - 2.0, - 3.0, - 15.0, + 1.0, 11.0, - 18.0, - 14.0, - 8.0, - 3.0, 6.0, - 13.0, + 12.0, + 7.0, + 14.0, + 11.0, + 4.0, 6.0, - 13.0, - 20.0, 3.0, - 9.0, 8.0, + 12.0, + 0.0, + 15.0, + 5.0, + 0.0, + 11.0, + 9.0, 10.0, - 13.0, - 13.0, + 11.0, 7.0, - 14.0, - 14.0, - 4.0, + 10.0, + 8.0, + 0.0, + 10.0, + 0.0, 6.0, 0.0, - 13.0, - 8.0, + 5.0, + 9.0, + 7.0, 13.0, 10.0, 12.0, - 13.0, + 10.0, + 8.0, 4.0, - 5.0, - 0.0, - 0.0, - 14.0, 2.0, + 13.0, 11.0, - 10.0, 12.0, + 13.0, + 11.0, + 12.0, + 13.0, 7.0, 7.0, - 12.0, - 9.0, - 17.0 + 10.0, + 14.0 ], - "washingstart": 17, - "timestamp": "2025-09-21T17:22:05.100330+02:00" + "washingstart": 14, + "timestamp": "2025-10-19T14:00:01.391083+02:00" } \ No newline at end of file diff --git a/src/version.py b/src/version.py index 805b4a86..8e523c93 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = '0.2.24' +__version__ = "0.2.25" diff --git a/src/web/css/style.css b/src/web/css/style.css index 74c5cf86..31b0eda4 100644 --- a/src/web/css/style.css +++ b/src/web/css/style.css @@ -32,7 +32,16 @@ body { border-radius: 10px; display: flex; flex-direction: column; - font-size: clamp(10px, 1.5vh, 18px); /* Dynamically scale font size */ + font-size: clamp(10px, 1.5vh, 18px); + /* Dynamically scale font size */ +} + +.top-box .top_box_info_text_head { + font-size: clamp(8px, 1.3vh, 16px); +} + +.top-box .top_box_info_text { + font-size: clamp(7px, 1.1vh, 14px); } .bottom-boxes { @@ -95,8 +104,9 @@ td { padding: 0 5px 0 5px; text-align: left; } + th { - text-align:center; + text-align: center; } #overlay_menu { @@ -116,7 +126,7 @@ th { } #overlay_menu_content_wrapper { - width: 100%; + width: 100%; /* height: 100%; */ border-radius: 10px; border: solid 1px rgba(255, 255, 255, 0.5); @@ -147,11 +157,11 @@ th { font-size: 0.73em; } - .top-box > .header { + .top-box>.header { font-size: 1.5em; } - .top-box > .content { + .top-box>.content { font-size: 1.2em; } @@ -174,7 +184,7 @@ th { font-size: smaller; } */ - .right-box > .content{ + .right-box>.content { font-size: 1.2em; } @@ -184,6 +194,7 @@ th { width: 85%; height: auto; } + #mobileview_rotate { display: block; } @@ -193,12 +204,15 @@ th { .top-boxes { display: none; } + .bottom-boxes { height: 100%; } + .left-box { width: 100%; } + .content { display: flex; flex-direction: column; @@ -206,9 +220,11 @@ th { align-items: center; height: 85%; } + .right-box { display: none; } + #mobileview_rotate { display: none; } @@ -222,7 +238,8 @@ th { font-size: calc(0.35em + 0.9vh); } -.table-header, .table-body { +.table-header, +.table-body { display: table-row-group; } diff --git a/src/web/index.html b/src/web/index.html index f92d09c7..e0364f68 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -26,10 +26,10 @@
- + - + @@ -108,7 +112,7 @@
- + @@ -117,15 +121,15 @@ - + - + - +
Overall StateOverall State
AC ChargeAC Charge --
DC ChargeDC Charge --
Discharge AllowedDischarge Allowed --
@@ -140,7 +144,7 @@
- + @@ -149,17 +153,19 @@ - - + - - + + - +
Next AC ChargeNext AC Charge ... next charge time
Needed Energy... kWh / - ... € + Needed Energyinitializing... / + ...
Avg AC Charging Price... kWhAvg AC Charging Priceinitializing... +
Dynamic Max AC+DC Charge PowerDynamic Max AC+DC Charge Power --
@@ -175,8 +181,9 @@ - +
... ... --.- % - - + + - - + + - +
Expense (rest of the day) 0.00 €Expense (rest of the day)0.00 ...
@@ -242,11 +249,12 @@
Income0.00 €Income0.00 + ...
Feed InFeed In 0.0 kWh
@@ -279,9 +287,13 @@
time
Control
target state
-
Price
ct/kWh
-
Expense
-
Income
+
Price
.../kWh
+
+ Pay/ Earn
... +
+
SOC
%
@@ -309,6 +321,25 @@
+
+ + Experimental Mode: EVopt is in charge +
@@ -325,4 +356,5 @@ + \ No newline at end of file diff --git a/src/web/index_legacy.html b/src/web/index_legacy.html index e7677ea7..e6b49fe1 100644 --- a/src/web/index_legacy.html +++ b/src/web/index_legacy.html @@ -1009,7 +1009,6 @@ // cell1.innerHTML = ((index + currentHour) % 24) + ":00"; const labelTime = new Date(serverTime.getTime() + (index * 60 * 60 * 1000)); cell1.innerHTML = labelTime.getHours().toString().padStart(2, '0') + ":00"; - cell1.style.textAlign = "right"; row.appendChild(cell1); diff --git a/src/web/js/battery.js b/src/web/js/battery.js index a30ac340..16dacaa3 100644 --- a/src/web/js/battery.js +++ b/src/web/js/battery.js @@ -48,7 +48,7 @@ class BatteryManager { foundFirst = true; } let current_hour_amount = value * max_charge_power_w; - let current_hour_price = price_data[index] * current_hour_amount; // Convert to ct/kWh + let current_hour_price = price_data[index] * current_hour_amount; // Convert to minor unit per kWh total_price += current_hour_price; total_price_count += 1; next_charge_amount += value * max_charge_power_w; @@ -67,19 +67,19 @@ class BatteryManager { if (nextChargeSummary2) nextChargeSummary2.style.display = "none"; } else { document.getElementById('next_charge_amount').innerText = (next_charge_amount / 1000).toFixed(1) + " kWh"; - + // Set total price const sumPriceElement = document.getElementById('next_charge_sum_price'); if (sumPriceElement) { - sumPriceElement.innerText = total_price.toFixed(2) + " €"; + sumPriceElement.innerText = total_price.toFixed(2) + " " + localization.currency_symbol; } - + // Set average price if element exists const avgPriceElement = document.getElementById('next_charge_avg_price'); if (avgPriceElement && !isNaN(next_charge_avg_price) && isFinite(next_charge_avg_price)) { - avgPriceElement.innerText = next_charge_avg_price.toFixed(1) + " ct/kWh"; + avgPriceElement.innerText = next_charge_avg_price.toFixed(1) + " " + localization.currency_minor_unit + "/kWh"; } - + // Display charge summary elements const nextChargeHeader = document.getElementById('next_charge_header'); const nextChargeSummary = document.getElementById('next_charge_summary'); @@ -96,4 +96,4 @@ function setBatteryChargingData(data_response) { if (batteryManager) { batteryManager.setBatteryChargingData(data_response); } -} \ No newline at end of file +} diff --git a/src/web/js/bugreport.js b/src/web/js/bugreport.js index 2925dd9f..0963733f 100644 --- a/src/web/js/bugreport.js +++ b/src/web/js/bugreport.js @@ -15,11 +15,11 @@ class BugReportManager { */ async showBugReportPopup() { console.log('[BugReport] Preparing bug report popup...'); - + // Get version information let versionInfo = 'Version unknown'; try { - const response = await fetch('/json/current_controls.json'); + const response = await fetch('json/current_controls.json'); if (response.ok) { const status = await response.json(); if (status.eos_connect_version) { @@ -29,14 +29,12 @@ class BugReportManager { } catch (error) { console.warn('[BugReport] Could not fetch version info:', error); } - const header = `
Create Bug Report
`; - const content = `
@@ -248,32 +246,31 @@ class BugReportManager {
`; - + showFullScreenOverlay(header, content); - + // Enable/disable the generate buttons based on form validation const titleInput = document.getElementById('bugTitle'); const descriptionInput = document.getElementById('bugDescription'); const generateUrlBtn = document.getElementById('generateUrlBtn'); - + function updateButtonState() { const isValid = titleInput.value.trim() !== '' && descriptionInput.value.trim() !== ''; - + // Update Copy button const copyDataBtn = document.getElementById('copyDataBtn'); copyDataBtn.disabled = !isValid; copyDataBtn.style.cursor = isValid ? 'pointer' : 'not-allowed'; copyDataBtn.style.opacity = isValid ? '1' : '0.6'; - // Update URL button generateUrlBtn.disabled = !isValid; generateUrlBtn.style.cursor = isValid ? 'pointer' : 'not-allowed'; generateUrlBtn.style.opacity = isValid ? '1' : '0.6'; } - + titleInput.addEventListener('input', updateButtonState); descriptionInput.addEventListener('input', updateButtonState); - + // Focus on title field setTimeout(() => titleInput.focus(), 100); } @@ -287,56 +284,54 @@ class BugReportManager { const titleInput = document.getElementById('bugTitle'); const descriptionInput = document.getElementById('bugDescription'); const generateUrlBtn = document.getElementById('generateUrlBtn'); - if (!titleInput.value.trim() || !descriptionInput.value.trim()) { alert('Please fill in both title and description fields.'); return; } - + // Show loading state generateUrlBtn.innerHTML = 'Collecting...'; generateUrlBtn.disabled = true; - + try { // Clean and encode description for URL - keep it simple let issueBody = descriptionInput.value.trim(); - + // Fix markdown formatting for URL encoding issueBody = issueBody .replace(/\*\*([^*]+)\*\*/g, '**$1**') // Fix bold formatting .replace(/##\s+/g, '## ') // Fix header spacing .replace(/\n\s*\n\s*\n/g, '\n\n') // Remove excessive newlines .replace(/\[([^\]]+)\]/g, '$1'); // Remove placeholder brackets - + // Keep the description clean for GitHub URL - + // Update button text generateUrlBtn.innerHTML = 'Opening...'; - + // Open GitHub with pre-filled form and bug label this.openGitHubIssueURL(titleInput.value.trim(), issueBody); - + // Close the popup after a brief delay setTimeout(() => { closeFullScreenOverlay(); }, 1000); - + } catch (error) { console.error('[BugReport] Error generating URL bug report:', error); - + // Show user-friendly error message - const errorMessage = error.message.includes('fetch') + const errorMessage = error.message.includes('fetch') ? 'Unable to collect system data. Opening GitHub form without system data.' : 'Error collecting system data. Opening GitHub form with basic information.'; - + alert(errorMessage); - + // Fallback: Open GitHub with just title and cleaned description let fallbackBody = descriptionInput.value.trim() .replace(/\[([^\]]+)\]/g, '$1') // Remove placeholder brackets .replace(/\n\s*\n\s*\n/g, '\n\n'); // Clean excessive newlines this.openGitHubIssueURL(titleInput.value.trim(), fallbackBody); - // Close popup setTimeout(() => { closeFullScreenOverlay(); @@ -468,11 +463,11 @@ class BugReportManager { */ generateIssueBody(description, systemData) { let body = ''; - + // Add user description body += '## Description\\n\\n'; body += description + '\\n\\n'; - + // Add system information body += '## System Information\\n\\n'; if (systemData.version) { @@ -483,12 +478,10 @@ class BugReportManager { body += `**Data Collection Errors:** ${systemData.errors.length} error(s) occurred\\n`; } body += '\\n'; - // Add system data as collapsed sections with size monitoring body += '## System Data\\n\\n'; let currentSize = body.length; const sizeLimit = this.maxBodySize - 1000; // Reserve space for footer and safety margin - // Helper function to add section if it fits const addSectionIfFits = (sectionTitle, sectionData, formatAsJson = true) => { let sectionContent = `
\\n${sectionTitle}\\n\\n`; @@ -498,7 +491,6 @@ class BugReportManager { sectionContent += '```\\n' + sectionData + '\\n```\\n\\n'; } sectionContent += '
\\n\\n'; - if (currentSize + sectionContent.length < sizeLimit) { body += sectionContent; currentSize += sectionContent.length; @@ -506,7 +498,6 @@ class BugReportManager { } return false; }; - // Current Controls (highest priority) if (systemData.currentControls) { if (!addSectionIfFits('Current Controls & States', systemData.currentControls, true)) { @@ -520,13 +511,13 @@ class BugReportManager { addSectionIfFits('Current Controls & States (Essential)', essentialControls, true); } } - + // Recent Error/Warning Alerts (high priority) - use LoggingManager alerts if (systemData.alerts) { - const errorAlerts = systemData.alerts.filter(alert => + const errorAlerts = systemData.alerts.filter(alert => alert.level === 'ERROR' || alert.level === 'WARNING' ); - + if (errorAlerts.length > 0) { let alertText = ''; errorAlerts.slice(-50).forEach(alert => { // Get last 50 error/warning alerts @@ -535,7 +526,6 @@ class BugReportManager { addSectionIfFits('Recent Error/Warning Alerts', alertText, false); } } - // Recent Logs (lower priority - general logs) if (systemData.recentLogs && systemData.recentLogs.logs && currentSize < sizeLimit * 0.7) { let allLogText = ''; @@ -544,29 +534,28 @@ class BugReportManager { }); addSectionIfFits('Recent Logs (Last 30 entries)', allLogText, false); } - // Optimization data (medium priority) if (systemData.optimizeResponse) { addSectionIfFits('Last Optimization Response', systemData.optimizeResponse, true); } - + if (systemData.optimizeRequest) { addSectionIfFits('Last Optimization Request', systemData.optimizeRequest, true); } - + // Data collection errors (if any) if (systemData.errors.length > 0) { const errorText = systemData.errors.join('\\n'); addSectionIfFits('Data Collection Errors', errorText, false); } - + // Add size information body += `\\n**Data Size Info:** ${Math.round(currentSize / 1024 * 10) / 10}KB / ${Math.round(sizeLimit / 1024)}KB limit\\n\\n`; - + // Add footer body += '---\\n'; body += '*This bug report was generated automatically by EOS Connect\'s built-in reporting feature.*'; - + return body; } @@ -575,11 +564,11 @@ class BugReportManager { */ generateTruncatedIssueBody(description, systemData, isUrlMode = false) { let body = ''; - + // Add user description body += '## Description\\n\\n'; body += description + '\\n\\n'; - + // Add system information body += '## System Information\\n\\n'; if (systemData.version) { @@ -590,14 +579,14 @@ class BugReportManager { body += `**Data Collection Errors:** ${systemData.errors.length} error(s) occurred\\n`; } body += '\\n'; - + // Add truncated system data body += '## System Data (Truncated)\\n\\n'; - const sizeNote = isUrlMode + const sizeNote = isUrlMode ? '_Note: System data was truncated for URL length limitations. For complete data, please use the "Auto-Create Issue" option._\\n\\n' : '_Note: System data was truncated due to size limitations. Please check the application logs for full details._\\n\\n'; body += sizeNote; - + // Add basic system info only if (systemData.currentControls) { const basicInfo = { @@ -610,13 +599,13 @@ class BugReportManager { body += '```json\\n' + JSON.stringify(basicInfo, null, 2) + '\\n```\\n\\n'; body += '\\n\\n'; } - + // Add only recent error alerts (using LoggingManager alerts) if (systemData.alerts && !isUrlMode) { - const errorAlerts = systemData.alerts.filter(alert => + const errorAlerts = systemData.alerts.filter(alert => alert.level === 'ERROR' || alert.level === 'WARNING' ).slice(-20); // Get last 20 error/warning alerts - + if (errorAlerts.length > 0) { body += '
\\nRecent Error/Warning Alerts (Last 20)\\n\\n'; body += '```\\n'; @@ -628,15 +617,14 @@ class BugReportManager { } } else if (systemData.alerts && isUrlMode) { // For URL mode, only show count of errors - const errorAlerts = systemData.alerts.filter(alert => + const errorAlerts = systemData.alerts.filter(alert => alert.level === 'ERROR' || alert.level === 'WARNING' ); - + if (errorAlerts.length > 0) { body += `**Recent Errors:** ${errorAlerts.length} error/warning entries in alerts\\n\\n`; } } - // Add data collection errors if any if (systemData.errors.length > 0) { body += '
\\nData Collection Errors\\n\\n'; @@ -647,12 +635,10 @@ class BugReportManager { body += '```\\n\\n'; body += '
\\n\\n'; } - // Add footer body += '---\\n'; body += '*This bug report was generated automatically by EOS Connect\'s built-in reporting feature.*\\n'; body += '*Full system data was truncated due to GitHub URL length limitations.*'; - return body; } @@ -662,30 +648,29 @@ class BugReportManager { async createGitHubIssue(title, body) { try { console.log('[BugReport] Attempting to create GitHub issue...'); - + // Check if we have a stored OAuth token let accessToken = this.getStoredGitHubToken(); - + // Try to create issue with current token (if any) let response = await this.attemptIssueCreation(title, body, accessToken); - + if (response && response.ok) { const result = await response.json(); console.log('[BugReport] GitHub issue created successfully'); console.log('[BugReport] Issue URL:', result.html_url); - // Open the created issue window.open(result.html_url, '_blank'); return true; } - + // Handle authentication required if (response && response.status === 401) { const result = await response.json(); - + if (result.auth_required) { console.log('[BugReport] Authentication required, starting OAuth flow...'); - + // Try OAuth authentication const newToken = await this.authenticateWithGitHub(); if (newToken) { @@ -700,7 +685,6 @@ class BugReportManager { } } } - // Server proxy not configured - try URL method if (response && response.status === 503) { const result = await response.json(); @@ -709,15 +693,14 @@ class BugReportManager { this.openGitHubIssueURL(title, body); return true; } - // If we get here, API methods failed - try URL method console.log('[BugReport] API methods failed, trying URL method...'); this.openGitHubIssueURL(title, body); return true; - + } catch (error) { console.error('[BugReport] Error in createGitHubIssue:', error); - + // Final fallback to issues overview page console.log('[BugReport] All methods failed, redirecting to issues overview...'); this.openGitHubIssuesOverview(); @@ -754,15 +737,14 @@ class BugReportManager { async authenticateWithGitHub() { try { console.log('[BugReport] Starting GitHub Device Flow authentication...'); - // Start GitHub Device Flow const authResponse = await fetch('/api/github/auth/start'); if (!authResponse.ok) { throw new Error('Failed to start GitHub authentication'); } - + const deviceData = await authResponse.json(); - + // Show device code to user const authPromise = new Promise((resolve) => { // Create modal with device code instructions @@ -805,18 +787,17 @@ class BugReportManager { `; - showFullScreenOverlay( '
GitHub Authentication
', authModal ); - + // Start polling for authentication this.pollGitHubAuth(deviceData.device_code, resolve); }); - + return await authPromise; - + } catch (error) { console.error('[BugReport] GitHub authentication error:', error); return null; @@ -830,23 +811,21 @@ class BugReportManager { const maxAttempts = 60; // 5 minutes with 5-second intervals let attempts = 0; let pollInterval = 5000; // Start with 5 seconds - const poll = async () => { if (attempts >= maxAttempts) { document.getElementById('authStatus').textContent = 'Authentication timeout. Please try again.'; setTimeout(() => resolve(null), 2000); return; } - try { const response = await fetch('/api/github/auth/poll', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ device_code: deviceCode }) }); - + const result = await response.json(); - + if (result.status === 'success') { document.getElementById('authStatus').innerHTML = ' Authentication successful!'; this.storeGitHubToken(result.access_token); @@ -856,21 +835,20 @@ class BugReportManager { }, 1000); return; } - + if (result.status === 'slow_down') { pollInterval += 2000; // Slow down polling } - + attempts++; setTimeout(poll, pollInterval); - + } catch (error) { console.error('[BugReport] Polling error:', error); attempts++; setTimeout(poll, pollInterval); } }; - // Start polling setTimeout(poll, 1000); } @@ -910,16 +888,16 @@ class BugReportManager { */ openGitHubIssuesOverview() { const issuesUrl = `https://github.com/${this.repoOwner}/${this.repoName}/issues`; - + console.log('[BugReport] Opening GitHub issues overview due to technical difficulties...'); - + // Show user-friendly message alert('There was a technical issue creating the pre-filled bug report.\n\n' + - 'You will be redirected to the GitHub issues page where you can:\n' + - '1. Click "New issue" to create a manual report\n' + - '2. Include the system data from your EOS Connect web interface\n' + - '3. Check the Logs section and JSON endpoints for debugging data'); - + 'You will be redirected to the GitHub issues page where you can:\n' + + '1. Click "New issue" to create a manual report\n' + + '2. Include the system data from your EOS Connect web interface\n' + + '3. Check the Logs section and JSON endpoints for debugging data'); + window.open(issuesUrl, '_blank'); } @@ -928,7 +906,6 @@ class BugReportManager { */ openGitHubIssueURL(title, body) { const baseUrl = `https://github.com/${this.repoOwner}/${this.repoName}/issues/new`; - // For very large bodies, we'll create a more structured approach if (body.length > 8000) { // Create a truncated version for URL and provide instructions @@ -938,7 +915,6 @@ class BugReportManager { body: truncatedBody, labels: 'bug' }); - const githubUrl = `${baseUrl}?${params.toString()}`; console.log('[BugReport] Opening GitHub with truncated data due to URL limits...'); window.open(githubUrl, '_blank'); @@ -948,7 +924,6 @@ class BugReportManager { body: body, labels: 'bug' }); - const githubUrl = `${baseUrl}?${params.toString()}`; console.log('[BugReport] Opening GitHub issue URL...'); window.open(githubUrl, '_blank'); @@ -960,7 +935,6 @@ class BugReportManager { */ generateUrlSafeBody(title, fullBody) { const maxUrlBodyLength = 6000; // Conservative limit for URL - if (fullBody.length <= maxUrlBodyLength) { return fullBody; } @@ -969,7 +943,6 @@ class BugReportManager { const lines = fullBody.split('\\n'); let safebody = ''; let currentLength = 0; - // Always include description section const descriptionEndIndex = lines.findIndex(line => line.startsWith('## System Information')); if (descriptionEndIndex > 0) { @@ -977,12 +950,10 @@ class BugReportManager { safebody = descriptionLines.join('\\n') + '\\n\\n'; currentLength = safebody.length; } - // Add system info safebody += '## System Information\\n\\n'; safebody += '_System data was truncated due to URL length limitations._\\n'; safebody += '_Full system data is available in EOS Connect logs and web interface._\\n\\n'; - // Add instructions for full data safebody += '## Full System Data\\n\\n'; safebody += 'To provide complete system data for debugging:\\n'; @@ -990,10 +961,10 @@ class BugReportManager { safebody += '2. Go to Logs section and export recent logs\\n'; safebody += '3. Check JSON endpoints: `/json/current_controls.json`, `/json/optimize_request.json`, `/json/optimize_response.json`\\n'; safebody += '4. Attach the relevant files to this issue\\n\\n'; - + safebody += '---\\n'; safebody += '_This bug report was generated automatically by EOS Connect. Full data truncated due to URL limitations._'; - + return safebody; } @@ -1006,7 +977,6 @@ class BugReportManager { if (existingModal) { existingModal.remove(); } - // Create modal HTML const modal = document.createElement('div'); modal.id = 'bugReportPreviewModal'; @@ -1024,7 +994,6 @@ class BugReportManager { padding: 20px; box-sizing: border-box; `; - modal.innerHTML = `
Copying...'; copyBtn.disabled = true; - + // Collect system data console.log('[BugReport] Collecting system data for clipboard...'); const systemData = await this.collectSystemData(); - + // Generate markdown content based on selections const markdownContent = this.generateMarkdownFromSelections(systemData); - + // Copy to clipboard with fallback let success = false; try { @@ -1132,17 +1099,16 @@ class BugReportManager { success = document.execCommand('copy'); document.body.removeChild(textArea); } - + if (!success) { throw new Error('All clipboard methods failed'); } - + // Show success state copyBtn.innerHTML = 'Copied!'; copyBtn.style.background = 'rgba(40, 167, 69, 0.2)'; copyBtn.style.borderColor = '#28a745'; copyBtn.style.color = '#28a745'; - // Reset button after 2 seconds setTimeout(() => { copyBtn.innerHTML = 'Copy to Clipboard'; @@ -1151,11 +1117,11 @@ class BugReportManager { copyBtn.style.color = '#ffc107'; copyBtn.disabled = false; }, 2000); - + } catch (error) { console.error('[BugReport] Error copying to clipboard:', error); alert('Failed to copy to clipboard. Please try again or copy manually.'); - + // Reset button copyBtn.innerHTML = 'Copy to Clipboard'; copyBtn.disabled = false; @@ -1167,21 +1133,20 @@ class BugReportManager { */ generateMarkdownFromSelections(systemData) { let markdown = '\n\n---\n\n## 🔧 System Data\n\n'; - // Check which items are selected const includeErrors = document.getElementById('include_errors').checked; const includeControls = document.getElementById('include_controls').checked; const includeOptRequest = document.getElementById('include_opt_request').checked; const includeOptResponse = document.getElementById('include_opt_response').checked; const includeLogs = document.getElementById('include_logs').checked; - + // Add errors/warnings first (most important) - use alerts like in preview if (includeErrors && systemData.alerts) { // Use same logic as preview - filter alerts for ERROR and WARNING only - const errorAlerts = systemData.alerts.filter(alert => + const errorAlerts = systemData.alerts.filter(alert => alert.level === 'ERROR' || alert.level === 'WARNING' ).slice(-10); // Get last 10 error/warning alerts - + if (errorAlerts.length > 0) { markdown += `### ⚠️ Recent Errors & Warnings (${errorAlerts.length} found)\n\n`; markdown += '```\n'; @@ -1195,7 +1160,6 @@ class BugReportManager { } else if (includeErrors) { markdown += '### ❌ Recent Errors & Warnings\n\nAlerts data not available.\n\n'; } - // Add current controls if (includeControls && systemData.currentControls) { markdown += '### 🎛️ Current System Controls & States\n\n'; @@ -1205,7 +1169,6 @@ class BugReportManager { markdown += '\n```\n\n'; markdown += '
\n\n'; } - // Add optimization request if (includeOptRequest && systemData.optimizeRequest) { markdown += '### 📤 Last Optimization Request\n\n'; @@ -1215,7 +1178,6 @@ class BugReportManager { markdown += '\n```\n\n'; markdown += '\n\n'; } - // Add optimization response if (includeOptResponse && systemData.optimizeResponse) { markdown += '### 📥 Last Optimization Response\n\n'; @@ -1225,7 +1187,6 @@ class BugReportManager { markdown += '\n```\n\n'; markdown += '\n\n'; } - // Add recent logs if (includeLogs && systemData.recentLogs && systemData.recentLogs.logs) { const recentLogs = systemData.recentLogs.logs.slice(0, 200); @@ -1238,11 +1199,11 @@ class BugReportManager { markdown += '```\n\n'; markdown += '\n\n'; } - + // Add footer markdown += '---\n'; markdown += '*This system data was generated automatically by EOS Connect bug reporting feature.*'; - + return markdown; } @@ -1252,15 +1213,14 @@ class BugReportManager { async previewData(dataType) { try { console.log(`[BugReport] Previewing ${dataType} data...`); - // Collect system data if not already available if (!this.cachedSystemData) { this.cachedSystemData = await this.collectSystemData(); } - + let content = ''; let title = ''; - + switch (dataType) { case 'errors': title = '⚠️ Recent Errors & Warnings (Last 10)'; @@ -1269,14 +1229,14 @@ class BugReportManager { if (typeof loggingManager !== 'undefined' && loggingManager.fetchAlerts) { await loggingManager.fetchAlerts(); const alerts = loggingManager.alerts || []; - + const errorAlerts = alerts .filter(alert => alert.level === 'ERROR' || alert.level === 'WARNING') .slice(-10); // Get most recent 10 - + if (errorAlerts.length > 0) { title = `⚠️ Recent Errors & Warnings (${errorAlerts.length} found)`; - content = errorAlerts.map(alert => + content = errorAlerts.map(alert => `
[${alert.level}] ${alert.timestamp}
${alert.message} @@ -1293,22 +1253,18 @@ class BugReportManager { content = '
❌ Error loading alerts data.
'; } break; - case 'controls': title = '🎛️ Current System Controls & States'; content = `
${JSON.stringify(this.cachedSystemData.currentControls || {}, null, 2)}
`; break; - case 'opt_request': title = '📤 Last Optimization Request'; content = `
${JSON.stringify(this.cachedSystemData.optimizeRequest || {}, null, 2)}
`; break; - case 'opt_response': title = '📥 Last Optimization Response'; content = `
${JSON.stringify(this.cachedSystemData.optimizeResponse || {}, null, 2)}
`; break; - case 'logs': title = '📋 Recent Log Entries (Last 200)'; if (this.cachedSystemData.recentLogs && this.cachedSystemData.recentLogs.logs) { @@ -1320,15 +1276,14 @@ class BugReportManager { content = '
No log data available.
'; } break; - default: title = 'Preview'; content = '
Unknown data type.
'; } - + // Show preview in smaller modal this.showPreviewModal(title, content); - + } catch (error) { console.error(`[BugReport] Error previewing ${dataType}:`, error); alert(`Failed to preview ${dataType} data. Please try again.`); @@ -1341,37 +1296,34 @@ class BugReportManager { async copyToClipboard(dataType) { try { console.log(`[BugReport] Copying ${dataType} to clipboard...`); - // Collect system data if not already available if (!this.cachedSystemData) { this.cachedSystemData = await this.collectSystemData(); } - + let markdown = ''; - + switch (dataType) { case 'controls': markdown = '### 🎛️ Current System Controls & States\n\n```json\n'; markdown += JSON.stringify(this.cachedSystemData.currentControls || {}, null, 2); markdown += '\n```'; break; - case 'opt_request': markdown = '### 📤 Last Optimization Request\n\n```json\n'; markdown += JSON.stringify(this.cachedSystemData.optimizeRequest || {}, null, 2); markdown += '\n```'; break; - case 'opt_response': markdown = '### 📥 Last Optimization Response\n\n```json\n'; markdown += JSON.stringify(this.cachedSystemData.optimizeResponse || {}, null, 2); markdown += '\n```'; break; - + default: throw new Error(`Unknown data type: ${dataType}`); } - + // Copy to clipboard with fallback let success = false; try { @@ -1397,24 +1349,22 @@ class BugReportManager { success = document.execCommand('copy'); document.body.removeChild(textArea); } - + if (!success) { throw new Error('All clipboard methods failed'); } - + // Show visual feedback const button = event.target.closest('button'); const originalContent = button.innerHTML; button.innerHTML = ''; button.style.borderColor = '#28a745'; button.style.color = '#28a745'; - setTimeout(() => { button.innerHTML = originalContent; button.style.borderColor = ''; button.style.color = ''; }, 1500); - } catch (error) { console.error(`[BugReport] Error copying ${dataType} to clipboard:`, error); alert(`Failed to copy ${dataType} data to clipboard. Please try again.`); diff --git a/src/web/js/chart.js b/src/web/js/chart.js index 696dc215..cbc0cd89 100644 --- a/src/web/js/chart.js +++ b/src/web/js/chart.js @@ -20,7 +20,7 @@ class ChartManager { /** * Update existing chart with new data */ - updateChart(data_request, data_response) { + updateChart(data_request, data_response, data_controls) { if (!this.chartInstance) { console.warn('[ChartManager] No chart instance to update'); return; @@ -30,14 +30,25 @@ class ChartManager { const serverTime = new Date(data_response["timestamp"]); const currentHour = serverTime.getHours(); + const evopt_in_charge = data_controls["used_optimization_source"] === "evopt"; + // Create labels in user's local timezone - showing only hours with :00 - this.chartInstance.data.labels = Array.from({ length: data_response["result"]["Last_Wh_pro_Stunde"].length }, + this.chartInstance.data.labels = Array.from( + { length: data_response["result"]["Last_Wh_pro_Stunde"].length }, (_, i) => { - // Create a new date object for each hour label in user's timezone const labelTime = new Date(serverTime.getTime() + (i * 60 * 60 * 1000)); - const hour = labelTime.getHours(); - return `${hour.toString().padStart(2, '0')}:00`; - }); + if (evopt_in_charge && i === 0) { + // Show current time as HH:MM for the first entry + const hour = labelTime.getHours(); + const minute = labelTime.getMinutes(); + return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; + } else { + // Show HH:00 for all other entries + const hour = labelTime.getHours(); + return `${hour.toString().padStart(2, '0')}:00`; + } + } + ); // Calculate consumption (excluding home appliances) this.chartInstance.data.datasets[0].data = data_response["result"]["Last_Wh_pro_Stunde"].map((value, index) => { @@ -88,6 +99,8 @@ class ChartManager { this.chartInstance.data.datasets[6].data = data_response["result"]["Kosten_Euro_pro_Stunde"]; this.chartInstance.data.datasets[7].data = data_response["result"]["Einnahmen_Euro_pro_Stunde"]; this.chartInstance.data.datasets[8].data = data_response["result"]["Electricity_price"].map(value => value * 1000); + this.chartInstance.data.datasets[8].label = `Electricity Price (${localization.currency_symbol}/kWh)`; + this.chartInstance.options.scales.y1.title.text = `Price (${localization.currency_symbol}/kWh)`; this.chartInstance.data.datasets[9].data = data_response["discharge_allowed"].slice(currentHour).concat(data_response["discharge_allowed"].slice(24, 48)); this.chartInstance.update('none'); // Update without animation @@ -96,7 +109,7 @@ class ChartManager { /** * Create new chart instance */ - createChart(data_request, data_response) { + createChart(data_request, data_response, data_controls) { const ctx = document.getElementById('energyChart').getContext('2d'); this.chartInstance = new Chart(ctx, { type: 'bar', @@ -111,14 +124,14 @@ class ChartManager { { label: 'Akku SOC', data: [], type: 'line', backgroundColor: 'blue', borderColor: 'lightblue', borderWidth: 1, yAxisID: 'y2' }, { label: 'Expense', data: [], type: 'line', borderColor: 'lightgreen', backgroundColor: 'green', borderWidth: 1, yAxisID: 'y1', stepped: true, hidden: true }, { label: 'Income', data: [], type: 'line', borderColor: 'lightyellow', backgroundColor: 'yellow', borderWidth: 1, yAxisID: 'y1', stepped: true, hidden: true }, - { label: 'Electricity Price', data: [], type: 'line', borderColor: 'rgba(255, 69, 0, 0.8)', backgroundColor: 'rgba(255, 165, 0, 0.2)', borderWidth: 1, yAxisID: 'y1', stepped: true }, + { label: `Electricity Price (${localization.currency_symbol}/kWh)`, data: [], type: 'line', borderColor: 'rgba(255, 69, 0, 0.8)', backgroundColor: 'rgba(255, 165, 0, 0.2)', borderWidth: 1, yAxisID: 'y1', stepped: true }, { label: 'Discharge Allowed', data: [], type: 'line', borderColor: 'rgba(144, 238, 144, 0.3)', backgroundColor: 'rgba(144, 238, 144, 0.05)', borderWidth: 1, fill: true, yAxisID: 'y3' } ] }, options: { scales: { y: { beginAtZero: true, title: { display: true, text: 'Energy (kWh)', color: 'lightgray' }, grid: { color: 'rgb(54, 54, 54)' }, ticks: { color: 'lightgray' } }, - y1: { beginAtZero: true, position: 'right', title: { display: true, text: 'Price (€)', color: 'lightgray' }, grid: { drawOnChartArea: false }, ticks: { color: 'lightgray', callback: value => value.toFixed(2) } }, + y1: { beginAtZero: true, position: 'right', title: { display: true, text: `Price (${localization.currency_symbol}/kWh)`, color: 'lightgray' }, grid: { drawOnChartArea: false }, ticks: { color: 'lightgray', callback: value => value.toFixed(2) } }, y2: { beginAtZero: true, position: 'right', title: { display: true, text: 'Akku SOC (%)', color: 'darkgray' }, grid: { drawOnChartArea: false }, ticks: { color: 'darkgray', callback: value => value.toFixed(0) } }, y3: { beginAtZero: true, position: 'right', display: false, title: { display: true, text: 'AC Charge', color: 'darkgray' }, grid: { drawOnChartArea: false }, ticks: { color: 'darkgray', callback: value => value.toFixed(2) } }, x: { grid: { color: 'rgb(54, 54, 54)' }, ticks: { color: 'lightgray', font: { size: 10 } } } @@ -128,11 +141,11 @@ class ChartManager { }, } }); - + // Set global reference for legacy compatibility chartInstance = this.chartInstance; - - this.updateChart(data_request, data_response); // Feed the content immediately after creation + + this.updateChart(data_request, data_response, data_controls); // Feed the content immediately after creation } /** @@ -167,15 +180,15 @@ class ChartManager { } // Legacy compatibility functions -function createChart(data_request, data_response) { +function createChart(data_request, data_response, data_controls) { if (chartManager) { - chartManager.createChart(data_request, data_response); + chartManager.createChart(data_request, data_response, data_controls); } } -function updateChart(data_request, data_response) { +function updateChart(data_request, data_response, data_controls) { if (chartManager) { - chartManager.updateChart(data_request, data_response); + chartManager.updateChart(data_request, data_response, data_controls); } } diff --git a/src/web/js/constants.js b/src/web/js/constants.js index 0bd699e9..49784b3b 100644 --- a/src/web/js/constants.js +++ b/src/web/js/constants.js @@ -11,6 +11,7 @@ const COLOR_MODE_DISCHARGE_ALLOWED = "lightgreen"; const COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST = "#3399FF"; const COLOR_MODE_DISCHARGE_ALLOWED_EVCC_PV = "lightgreen"; const COLOR_MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV = "darkorange"; +const COLOR_MODE_CHARGE_FROM_GRID_EVCC_FAST = "rgb(255, 144, 144)"; const EOS_CONNECT_ICONS = [ { icon: "fa-plug-circle-bolt", color: COLOR_MODE_CHARGE_FROM_GRID, title: "Charge From Grid" }, @@ -18,7 +19,8 @@ const EOS_CONNECT_ICONS = [ { icon: "fa-battery-half", color: COLOR_MODE_DISCHARGE_ALLOWED, title: "Discharge Allowed" }, { icon: "fa-charging-station", color: COLOR_MODE_AVOID_DISCHARGE_EVCC_FAST, title: "Avoid Discharge Due to E-Car Fast Charge" }, { icon: "fa-charging-station", color: COLOR_MODE_DISCHARGE_ALLOWED_EVCC_PV, title: "Discharge Allowed During E-Car Charging in PV Mode" }, - { icon: "fa-charging-station", color: COLOR_MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV, title: "Discharge Allowed During E-Car Charging in Min+PV Mode" } + { icon: "fa-charging-station", color: COLOR_MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV, title: "Discharge Allowed During E-Car Charging in Min+PV Mode" }, + { icon: "fa-charging-station", color: COLOR_MODE_CHARGE_FROM_GRID_EVCC_FAST, title: "Charge From Grid During E-Car Fast Charge" } ]; // Global managers - will be initialized in main.js @@ -52,7 +54,7 @@ const TEST_SCENARIOS = { OVERRIDE_1: 'override_1', OVERRIDE_2: 'override_2', SINGLE_EVCC: 'single_evcc', - MULTI_EVCC: 'multi_evcc', + MULTI_EVCC: 'multi_evcc', NO_EVCC: 'no_evcc' }; diff --git a/src/web/js/controls.js b/src/web/js/controls.js index 6e51090a..1657ec89 100644 --- a/src/web/js/controls.js +++ b/src/web/js/controls.js @@ -86,16 +86,16 @@ class ControlsManager { min-width: 150px; "> ${Array.from({ length: 48 }, (_, i) => { - const hours = Math.floor((i + 1) / 2); - const minutes = ((i + 1) % 2) * 30; - const timeLabel = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; - return ``; - }).join('')} + const hours = Math.floor((i + 1) / 2); + const minutes = ((i + 1) % 2) * 30; + const timeLabel = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + return ``; + }).join('')}
- ${ currentModeNum === 0 && overrideActive ? '' : ` + ${currentModeNum === 0 && overrideActive ? '' : `
Grid Charge Power (kW)
Mode '${EOS_CONNECT_ICONS[0].title}' Only @@ -166,19 +166,19 @@ class ControlsManager {
${EOS_CONNECT_ICONS.slice(0, 3).map((icon, index) => { - // Mode numbers in data are 1-based (1,2,3) but our array is 0-based (0,1,2) - // So we need to compare (currentModeNum - 1) with index, OR currentModeNum with (index + 1) - // const isCurrentMode = overrideActive && (currentModeNum === (index + 1)); - const isCurrentMode = (currentModeNum === (index)); - const isDisabled = isCurrentMode; - const buttonColor = isDisabled ? '#666' : icon.color; - const bgColor = isDisabled ? 'rgba(51, 51, 51, 0.8)' : 'rgba(58, 58, 58, 0.8)'; - console.log(`[ControlsManager] Mode ${index} - isCurrentMode: ${isCurrentMode}, isDisabled: ${isDisabled}, currentModeNum: ${currentModeNum}, bgcolor: ${bgColor}`); - // const borderColor = isDisabled ? '#444' : icon.color; - const borderColor = icon.color; // show border color if Disabled it will shown with 0.5 opacity - const cursor = isDisabled ? 'not-allowed' : 'pointer'; - - return ` + // Mode numbers in data are 1-based (1,2,3) but our array is 0-based (0,1,2) + // So we need to compare (currentModeNum - 1) with index, OR currentModeNum with (index + 1) + // const isCurrentMode = overrideActive && (currentModeNum === (index + 1)); + const isCurrentMode = (currentModeNum === (index)); + const isDisabled = isCurrentMode; + const buttonColor = isDisabled ? '#666' : icon.color; + const bgColor = isDisabled ? 'rgba(51, 51, 51, 0.8)' : 'rgba(58, 58, 58, 0.8)'; + console.log(`[ControlsManager] Mode ${index} - isCurrentMode: ${isCurrentMode}, isDisabled: ${isDisabled}, currentModeNum: ${currentModeNum}, bgcolor: ${bgColor}`); + // const borderColor = isDisabled ? '#444' : icon.color; + const borderColor = icon.color; // show border color if Disabled it will shown with 0.5 opacity + const cursor = isDisabled ? 'not-allowed' : 'pointer'; + + return `